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, config-surface (the same action/transform/when catalog, machine-readable).

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: a duplicate is rejected when you save the draft, with step ids within a flow must be unique. 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', 'dissociate', 'export_file', 'find', 'find_associated', 'notify', 'plugin', 'post', 'promote_identity', 'query', 'skip', 'submit_form', 'transform', 'update', 'update_by_id', 'update_many', 'upload_file', 'upsert', 'upsert_many']

Every action except the 5 *_many bulk ones is available under config version: "3.0". The bulk five (upsert_many, create_many, update_many, delete_many, associate_many) require version: "3.1" per connector.md.

ActionWhat it does
transformProject the event into $.canonical through a schema’s field mappings.
findLook one record up (HubSpot search, or a GET on a REST destination).
find_associatedRead a record’s associated objects and hydrate them.
upsert, create, update, deleteWrite one record, resolving identity from the entity’s identity property.
update_by_idPATCH a HubSpot record by an id you already hold.
associateAssociate two records resolved by external id (single pair, or 1->N), optionally reconciling the whole set with replace.
dissociateRemove an association between two records resolved by external id (single pair, or 1->N).
promote_identityMove an identity mapping from one external id to another.
upload_fileAttach a file to a deal as a Note.
delete_attachmentsArchive the Note(s) carrying a deal’s attachment.
submit_formSubmit a HubSpot form.
postSend an HTTP request to a destination.
notifyBest-effort webhook, failures never fail the flow.
pluginRun your own code for logic the DSL does not cover.
queryRead every HubSpot record matching filters, optionally one calendar day of them.
export_fileWrite a list of records as a CSV file to plugsync-managed storage.
skipAbort the flow when reached.
upsert_many, create_many, update_many, delete_manyBatch writes (v3.1).
associate_manyBatch associations (v3.1).

A step accepts unknown extra fields: they pass validation instead of being rejected, are kept as written, and are never read. Treat the per-action tables below as the authoritative list of fields the runtime consumes. A typo at the step level is therefore not rejected when you save the draft, but preflight reports it, and so does the warnings list of a publish response:

  • unknown_step_key: the step declares a key that its action does not read. The message names the step, the flow, the key and the action, says the key is ignored at runtime (so a successful publish does not prove it takes effect), suggests the closest key the action does read when there is one, and lists the keys the action reads. If the key is intentional metadata, ignore the warning.
  • typed_step_field_wrong_action: the step declares a field that exists on other actions but not on this one, such as chunk_size on a single upsert.

A typed field that would be ignored in a way that hides a mistake is rejected outright instead; the tables below say which.

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. With items, resolved per element against $._item.
itemsnoArray form (1->N): $... ref to a list; the from record is associated to every element.
chunk_sizenoArray form batch page size (default 100, max 100).
association_type_idnoExplicit HubSpot association type id (e.g. 279 contact<->company). Omitted: auto-discovery.
association_categorynoCategory of association_type_id: HUBSPOT_DEFINED, USER_DEFINED or INTEGRATOR_DEFINED. Requires association_type_id. Default: absent, the category is read from the portal (see Association category).
replacenoRequires items. After associating the list, removes the associations the list does not contain (see below).

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

The portal must hold an association definition for the pair

Auto-discovery can only pick a type that already exists on the portal. Between two standard objects HubSpot ships its own definitions, so there is always one. Between two custom objects there is none until you create it, and plugsync never creates it — see Association definitions involving a custom object for how to create it, from the HubSpot UI or with the association-labels API.

While the definition is missing, the preflight (GET /api/connectors/{connector_id}/preflight, the Review-changes drawer, plugsync validate) reports the error association_definition_missing naming the two object types, and at runtime the step fails with no association definition between <from> and <to> on the portal. Both name the pair, because HubSpot’s own rejection does not.

The opposite case reads differently on purpose: when the pair does have definitions but an explicit association_type_id is not one of them, the remedy is the config, not the portal, and the failure says association type id <n> is not a valid association definition between <from> and <to> on this portal. Preflight reports that one as unknown_association_type_id, listing the pair’s valid ids.

Association category

On HubSpot an association type is identified by its id and its category: HUBSPOT_DEFINED for the types HubSpot ships, USER_DEFINED for the labels created on the portal, INTEGRATOR_DEFINED for the ones an app defines. The two are independent, so a custom label can carry the same id as a HubSpot-defined type on the same pair of objects (for instance a USER_DEFINED label with id 3 on deal -> contact, where 3 is also HubSpot’s own unlabeled type).

association_category names the category the step means. It is optional and only valid next to association_type_id:

{"id": "link_decision_maker", "action": "associate", "target": "hubspot", "from_entity": "deal", "from_external_id": "$.payload.deal_id", "to_entity": "contact", "to_external_id": "$.payload.contact_id", "association_type_id": 3, "association_category": "USER_DEFINED"}
association_categoryCategory written
absent (default)The category the portal reports for that id on the pair. An id that sits in one category only is unaffected; when the portal defines it in more than one, or reports no category for it, the category written is not guaranteed.
HUBSPOT_DEFINED, USER_DEFINED, INTEGRATOR_DEFINEDExactly that one, without reading it from the portal.

The same key is accepted by dissociate, by associate_many and by each entry of an associations list. With replace only the associations carrying the id in the declared category are candidates for removal.

Validation, when the config is saved:

  • any other value is rejected, naming the three valid ones;
  • association_category without association_type_id is rejected: without an id the type is discovered, category included;
  • on a step of another action it is rejected like association_type_id is.

The preflight (GET /api/connectors/{connector_id}/preflight, the Review-changes drawer, plugsync validate) checks associate and dissociate steps against the portal:

FindingSeverityWhen
ambiguous_association_type_idwarningThe step sets association_type_id without association_category, and the portal defines that id in more than one category for the pair (the message names them), or reports no category for it. The category written at runtime is then not guaranteed, and setting association_category removes the ambiguity.
association_category_mismatcherrorThe step sets an association_category in which the portal does not define that id for the pair. The message lists the categories it is defined in. HubSpot rejects such a write, so the preflight reports it as an error.

At runtime, a write HubSpot rejects for a type sent with a declared category fails with association type id <n> is not defined in category <category> between <from> and <to> on this portal: check association_type_id and association_category in the connector config. The remedy is the config, not the portal’s labels.

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"}

replace: the association set becomes the list you passed

associate only ever ADDS. When the external system is authoritative over a whole set (“these are the contacts of this company now”), the records dropped from the payload stay associated forever, because nothing in the event names them.

replace: true closes that: after associating every element of items, the step reads the source record’s current associations to to_entity and removes the ones whose record is not in the list. It requires items - a single pair carries no list to reconcile against, so replace without items is refused when you save the draft, and reported by the preflight as associate_replace_without_items on a version that predates the check.

{"id": "sync_company_contacts", "action": "associate", "target": "hubspot", "from_entity": "company", "from_external_id": "$.payload.idarca_cliente", "to_entity": "contact", "items": "$.payload.contatti", "to_external_id": "$._item.idarca_contatto", "replace": true}

With the company already associated to contacts A, B and C, an event carrying A and D leaves it associated to A and D: D is added, B and C are removed, A is untouched (re-associating an existing pair is idempotent).

What replace never removes:

  • Primary / 1:1 associations. A label containing “Primary”, and HubSpot’s own 1:1-constrained type ids, are left in place whatever the list says: they are the record’s main company link, no per-event list can express them, and nothing would restore one. Manage those with an explicit dissociate.
  • Associations to other object types. The reconcile is scoped to to_entity’s object type; a company’s deals are untouched by a step that reconciles its contacts.
  • Records outside the first page of the object’s current associations to that type. The reconcile is meant for the cardinalities a per-event list carries, not for a backfill.

Everything else in scope IS removed, including associations that were never created by this connector: a link somebody added by hand in HubSpot, or one another system created, is not in the event’s list and therefore goes. That is the whole meaning of replace - the source system owns the set - so only turn it on where the source really is the single authority over those associations.

With association_type_id set, replace narrows further: only the associations carrying that label are candidates for removal, and only that label is removed (with the label’s own category, so custom labels work; with association_category set, the label in that category).

When replace reconciles, and when it holds back

The reconcile treats the list as complete, so it runs only when the list can be trusted to be complete.

An empty list reconciles. items resolving to [] is a statement, not a gap: the source answered “this record has none of these any more”, and every non-protected association goes. A change_client event whose contact list is empty detaches all the contacts, which is exactly the point of “leave associated exactly the elements of the list”.

A list that cannot be trusted does not reconcile. In these cases the step associates what it can, removes nothing, and records a step warning (associate_replace_skipped, visible in the event’s step trace and in the dry run) saying which case it was:

CaseWhy nothing is removed
from_external_id resolved to nothingWithout the source record there is no association set to reconcile.
An element of the list has no identity mapping yetThat record could not be associated either, so removing “everything not in the list” would detach it precisely because its own sync is late.
The list is non-empty but no element resolved a to_external_idA partial or misshapen payload is never an authority on the whole set. The step is also traced as skipped_missing_ref, because it associated nothing.

items pointing at something that is not a list at all (an absent path, a string) stays what it already was for associate: a hard error on the event, louder than any of the above.

The removals are part of the step’s output (removed), so the event’s step trace names exactly which associations went. The dry run of the flow lists the same removals without performing any of them.

Stub upsert for a not-yet-synced FK

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 makes sure the identity mapping exists.

[ {"id": "stub_cliente", "action": "upsert", "target": "hubspot", "entity": "cliente", "fields": ["acme_erp_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”. The same applies on the batch path, where the record is reported as failed inside its own chunk.

dissociate

Removes an association between two records, each resolved by its external id - the mirror image of associate, same endpoint resolution and same optional 1->N items form.

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. With items, resolved per element against $._item.
itemsnoArray form (1->N): $... ref to a list; the association to every element is removed.
association_type_idnoRemoves only that label, with the label’s own category read from the portal (so custom labels work). Omitted: every label between the two records goes.
association_categorynoCategory of association_type_id: HUBSPOT_DEFINED, USER_DEFINED or INTEGRATOR_DEFINED. Requires association_type_id. Default: absent, the category is read from the portal (see Association category).

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

Example (a client update detaches a contact from its company):

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

Example (1->N: detach every element of a list, e.g. the records a find_associated step returned):

{"id": "unlink_stale", "action": "dissociate", "target": "hubspot", "from_entity": "company", "from_external_id": "$.payload.idarca_cliente", "to_entity": "contact", "items": "$find_stale.contacts", "to_external_id": "$._item.properties.idarca_contatto"}

How much of the pair goes

Without association_type_id the two records are detached entirely: every label between them goes in one call, whatever the labels are and whoever created them. That is the form to use when “this contact is no longer part of this company” is the whole intent.

With association_type_id only that one label goes, and the rest of the pair survives - a company and a contact linked both by the portal’s default association and by a custom “operational contact” label keep the first when you remove the second. The label’s category (HubSpot’s own, or a custom one defined on the portal) is read from the portal’s association types before the call, so custom labels work without declaring anything else: HubSpot refuses a custom label removed as if it were one of its own. An id the portal does not know is reported by the preflight as unknown_association_type_id listing the pair’s valid ids. When the same id exists in two categories on the pair, set association_category to say which label goes: without it the one removed is not guaranteed, and the preflight reports the warning ambiguous_association_type_id.

Both endpoints are resolved through the identity map, exactly like associate: there is no form that takes a HubSpot object id directly. An endpoint that does not resolve - an empty ref, or an external id this connector never synced - removes nothing and does not fail: the step is reported in the trace with trace_status: "skipped_missing_ref" and an empty removed list, the same honest no-op a removal of something that is not there deserves. Removing an association that does not exist is equally quiet, so a redelivered event is safe.

The step’s output carries removed (one entry per association actually removed), and the dry run of the flow lists those removals without performing any of them.

The removal is per pair: one call per element in the array form. It is meant for the cardinalities a single event carries, not for mass cleanups.

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).
mapped_properties_onlynotrue: send only the keys of data that are properties the entity maps. Default: false, every key of data is sent (see Writing only the mapped properties).

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).

Writing only the mapped properties

By default update_by_id sends every key of the resolved data to HubSpot as a property. With "data": "$.payload" that includes the keys your system only sends to route the event, and HubSpot refuses the whole update with PROPERTY_DOESNT_EXIST as soon as one key is not a property of the object.

Set mapped_properties_only: true to send only the keys that are properties the step’s entity maps:

  • the target of each of the entity’s hubspot field mappings;
  • the entity’s identity property.

Every other key of data is dropped before the request.

{"id": "patch_contact", "action": "update_by_id", "target": "hubspot", "entity": "contact", "object_id": "$.payload.hs_id", "data": "$.payload", "mapped_properties_only": true}

With a contact entity mapping firstname and phone, a payload {"hs_id": "501", "firstname": "Ada", "phone": "+39 02 1234", "route": "emea"} updates firstname and phone only.

What the option does and does not do:

  • It is a filter on the keys of data, matched by name against the mapped property names. The match is exact and case-sensitive: FirstName is not firstname. The mappings’ source and transform are not applied: a key is sent with the value data carries, under the name data gives it. A payload key named differently from the HubSpot property it feeds is dropped; to rename or transform, keep data as an inline object.
  • Mapping a property on the entity is the only way to let it through. A property the step sets itself in an inline data object (an owner, a stage, a fixed value) is dropped like any other key unless the entity maps it.
  • A mapping whose target starts with $. writes the canonical side, not HubSpot, so it adds no property to the set. A mapping with a target_template names its property per event, so its properties are not in the set either.
  • It applies to an inline data object too, and to every element of the array form.
  • data must resolve to an object. Anything else fails the step. In the array form that stops the step at that element: the elements before it stay written.
  • It costs no extra HubSpot call.

When nothing is left to send. If no key of data is a mapped property the step makes no HubSpot call. This is not a failure: nothing is retried and the flow goes on to its next step. It is not reported as a write either:

  • The step’s output is {"updated": false, "reason": "no_mapped_properties"} and its trace status is skipped_no_mapped_properties, not success.
  • The step carries a warning with code no_mapped_properties and a dropped_keys list: the names of the keys that were dropped, never their values. A misspelt or differently cased key shows up here.
  • If no other step of the flow wrote, the event is recorded as skipped with skip_kind no_write_step, like any flow that ran to its end without a write (see Why a record was skipped). If another step wrote, the event stays a success and the warning stays on the step.

In the array form an element with nothing mapped is skipped and the others are written. The step then carries one no_mapped_properties warning with skipped_items (how many elements were left out) and the dropped_keys of those elements. The trace status becomes skipped_no_mapped_properties only when no element at all was written.

The dry run shows the same status and warning, so a payload sample is enough to check the key names before publishing.

mapped_properties_only is accepted on update_by_id only. A draft is refused when the step’s entity maps no HubSpot property at all, since the step would never write. The Review changes drawer warns (update_by_id_unmapped_data_key) when an inline data object names a key the entity does not map.

Omitted or false, the step behaves exactly as described above: nothing changes for a connector that does not set it.

upload_file

Uploads a staged file to the HubSpot Files API and attaches it to a deal via a CRM v3 Note. At ingest the Event API sets a base64 file field aside and replaces it with a reference {s3_key, filename, numdoc, version} (see Event API: file attachments); this action resolves that reference, fetches the bytes, uploads them, creates a Note carrying hs_attachment_ids, associates the Note to the deal, then removes the staged file.

Prerequisites. The action only has something to upload when the connector declares where the file is:

  • settings.file_field names the base64 field inside the event data (a dotted path such as "linkfile", not a $.payload.* reference). Without it the field reaches the flow as a raw string, and upload_file is a no-op traced as skipped_missing_ref.
  • settings.file_numdoc_field / file_version_field (each with an optional *_transform) name the parts of the file name {numdoc}_{version}.pdf. The full name is this action’s idempotency key; numdoc alone is what delete_attachments matches on.
  • The file is sent on POST /api/v1/events with the data shape, as a bare base64 string. A file inside an entities entry or a bulk import row is not set aside.
  • The HubSpot connection grants the files.read and files.write scopes. They are not part of the base install: the Review changes drawer flags them before you publish, with a Reconnect HubSpot action that asks HubSpot for them, and a publish without them is refused (see also the error policy below).

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 when the same event is delivered twice: 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 are transient and the event is retried; 401/403 are fatal — the event is not completed, and a streak of consecutive auth failures on the same flow suspends that flow until the credential or the missing crm.objects.notes / files scope is fixed (any non-fatal outcome resets the streak, and the connector’s other flows keep syncing meanwhile); other 4xx are swallowed so the deal still syncs without the PDF.

Example:

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

Complete example: offer PDF on a deal

An ERP sends each revision of an offer as an offer_updated event. The document number carries the revision (9000/V2 is revision 2 of offer 9000), datadoc is the offer date, and linkfile holds the PDF. The connector keeps one deal per offer, attaches each revision’s PDF to it, stamps the last update date, and writes each revision’s date to its own numbered property (data_offerta_1, data_offerta_2, …).

The event, sent to the Event API:

{ "event": "offer_updated", "timestamp": "2026-09-11T09:30:00Z", "data": { "deal": {"id": "9000", "numerodoc": "9000/V2", "title": "Offer 9000", "amount": 12500, "datadoc": "2026-09-10"}, "linkfile": "JVBERi0xLjQKJ..." } }

The config (only the parts that matter here):

{ "settings": { "file_field": "linkfile", "file_numdoc_field": "deal.numerodoc", "file_numdoc_transform": [{"name": "split", "args": {"sep": "/", "index": 0}}], "file_version_field": "deal.numerodoc", "file_version_transform": [{"name": "split", "args": {"sep": "/", "index": 1, "default": "V0"}}] }, "entities": [ { "name": "deal", "schemas": { "hubspot": { "object_type": "deals", "identity": {"strategy": "external_id", "property": "deal_external_id"}, "field_mappings": [ {"source": "$.payload.deal.id", "target": "deal_external_id"}, {"source": "$.payload.deal.title", "target": "dealname"}, {"source": "$.payload.deal.amount", "target": "amount", "transform": "to_float"}, {"source": "$.occurred_at", "target": "deal_last_offer_date", "transform": "iso_to_date", "hubspot_type": "date", "hubspot_field_type": "date"}, {"source": "$.payload.deal.datadoc", "target_template": "data_offerta_{$.canonical.offer_idx}", "provision_names": ["data_offerta_1", "data_offerta_2", "data_offerta_3", "data_offerta_4", "data_offerta_5"], "transform": "iso_to_date", "hubspot_type": "date", "hubspot_field_type": "date"} ] }, "offer_index": { "field_mappings": [ {"source": "$.payload.deal.numerodoc", "target": "$.canonical.offer_idx", "transform": [ {"name": "split", "args": {"sep": "/", "index": 1, "default": "V0"}}, {"name": "regex_extract", "args": {"pattern": "\\d+"}}, {"name": "to_int_lenient"}, {"name": "add", "args": {"n": 1}}, {"name": "clamp", "args": {"min": 1, "max": 5}} ]} ] } } } ], "flows": [ { "name": "offer_updated", "source": "event_api", "event_type": "offer_updated", "steps": [ {"id": "stamp_offer_idx", "action": "transform", "entity": "deal", "from_schema": "offer_index", "to_schema": "canonical"}, {"id": "upsert_deal", "action": "upsert", "target": "hubspot", "entity": "deal", "data": "$.payload"}, {"id": "upload_offer_pdf", "action": "upload_file", "target": "hubspot", "file_ref": "$.payload.linkfile", "object_id": "$upsert_deal.id", "note_body": "Updated offer {filename}", "when": {"exists": "$.payload.linkfile"}} ] }, { "name": "offer_deleted", "source": "event_api", "event_type": "offer_deleted", "steps": [ {"id": "find_deal", "action": "find", "target": "hubspot", "entity": "deal", "by": {"property": "deal_external_id", "value": "$.payload.deal.id"}}, {"id": "cleanup_offer_pdf", "action": "delete_attachments", "target": "hubspot", "file_ref": "$.payload.linkfile", "object_id": "$find_deal.id", "numdoc_ref": "$.payload.deal.numerodoc", "numdoc_transform": [{"name": "split", "args": {"sep": "/", "index": 0}}], "when": {"found": "$find_deal"}} ] } ] }

What happens to the event above:

  1. Ingest. settings.file_field points at linkfile: the base64 string is set aside and linkfile becomes {"s3_key": "...", "filename": "9000_V2.pdf", "numdoc": "9000", "version": "V2"}. numdoc is 9000/V2 split on /, first part; version is the second part (V0 when the number has no /).
  2. stamp_offer_idx runs the offer_index schema, a computation-only schema whose single mapping writes to canonical: 9000/V2 -> V2 -> 2 -> 2 -> 3 -> 3 (clamped to 1..5), so $.canonical.offer_idx is 3. The +1 makes the first revision (V0) land in data_offerta_1; the clamp keeps later revisions inside the five provisioned slots. See transform.
  3. upsert_deal writes the deal. deal_last_offer_date reads $.occurred_at, the event’s timestamp (the ingest instant when the sender omits it), cut to 2026-09-11 by iso_to_date. The target_template resolves to data_offerta_3 and receives 2026-09-10. Both are HubSpot date properties: hubspot_type / hubspot_field_type make publish create them as dates, and provision_names lists every name the template can produce so all five slots exist before the first event. See computed targets and field-mapping roots.
  4. upload_offer_pdf uploads 9000_V2.pdf, attaches it to the deal with the note Updated offer 9000_V2.pdf, and removes the staged file. The when: exists guard skips the step on revisions sent without a file and on a file dropped at ingest as invalid base64 (its field is null). A redelivery of the same event finds the note already there and uploads nothing.

The offer_deleted flow is the cleanup side: a delete event usually carries no file, so delete_attachments takes the match key from numdoc_ref + numdoc_transform (the same split as file_numdoc_transform, so both sides agree on 9000) and archives every note on the deal whose file name starts with 9000_.

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. Pointing it at a schema with no $.canonical.* targets leaves canonical {}, which preflight flags (see below).

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

Common mistake: 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 (GET .../preflight) 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 (limit 1) by one condition, or by several combined in OR; 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 — same auth/header resolution as post, same by conditions, same limit of one record, same null-on-not-found output shape.

FieldRequiredNotes
targetyeshubspot, or a REST destination (event source) name.
entitywhen target is hubspotDrives the HubSpot object type.
byyesOne condition, {"property": "<hubspot property, or REST result item field>", "value": "<$-ref>"} with an optional operator, or several in OR: {"any": [<condition>, ...]}. See find conditions.
propertiesnoHubSpot only: 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'.

find conditions

A condition is {"property": ..., "value": ..., "operator": ...}. operator is optional:

operatorMatches
EQ (default)The property equals the value.
CONTAINS_TOKENOne whole entry of a multi-value property equals the value: a multiple checkboxes property, or a ;-separated one such as hs_additional_emails.

by.any lists 1 to 5 conditions and finds a record that satisfies any of them (OR). It replaces property/value/operator at the top of by: the two forms cannot be mixed. There is no AND between conditions.

{"id": "find_contact", "action": "find", "target": "hubspot", "entity": "contact", "by": {"any": [ {"property": "email", "value": "$.payload.email"}, {"property": "hs_additional_emails", "operator": "CONTAINS_TOKEN", "value": "$.payload.email"} ]}}

Rules that apply as soon as by carries an operator or an any list:

  • Which record wins. find still returns one record. On HubSpot, when several records match, the one with the lowest record id is returned. The order of the conditions inside any sets no priority: a record matching the last condition wins over one matching the first if its id is lower. To prefer one condition over another, use successive find steps guarded by when: {not: {found: ...}}. On a REST destination the first matching item in the order of the response is returned.
  • Conditions without a usable value are skipped. A condition whose value resolves to nothing (the event does not carry the field, or it is blank) is left out of the search. So is a condition whose value resolves to a list or an object, and on HubSpot a CONTAINS_TOKEN condition whose value contains a *, which HubSpot would read as a wildcard. When no condition is left, the step finds nothing and makes no call.
  • CONTAINS_TOKEN matches whole entries only. With ada@example.com, a record holding grada@example.com is not a match. Comparison ignores case and surrounding spaces on HubSpot; on a REST destination the item field is either a list or a ;-separated string, and entries are compared exactly after trimming spaces. On HubSpot the step reads up to 100 candidates to tell whole entries from partial ones; a value so generic that all 100 are partial matches fails the event with a clear error instead of answering “not found”. The exception is an any list where an EQ condition already matched a record with a lower id than all 100: that record is returned.
  • Returned properties with CONTAINS_TOKEN on HubSpot. The matched object carries the properties listed in properties plus the ones the CONTAINS_TOKEN conditions search, whichever condition of an any list matched it (an EQ one included). List in properties everything later steps read from it.
  • Booleans. A boolean value is sent to HubSpot as true / false. On a REST destination it is compared with a boolean field of the item, or with the booleans of a list under CONTAINS_TOKEN.
  • Publish checks. The shape is validated when the draft is saved (unknown operator, unknown key in a condition, more than 5 conditions, a condition without property or value). The publish preview reports unknown_find_property when a condition searches a HubSpot property that does not exist on the connected portal.

A by with just property and value behaves exactly as it always has: one EQ search with no explicit ordering: with several matching records, which one is returned is decided by HubSpot’s default search order, which plugsync does not control. Add "operator": "EQ" to get the explicit lowest-record-id ordering described above.

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": "orbit-crm", "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": "orbit-crm", "url": "/countries", "results_path": "$.data", "by": {"property": "name", "value": "$.payload.country"}, "cache_ttl_seconds": 3600}, {"id": "find_region", "action": "find", "target": "orbit-crm", "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 an in-process cache: it is not shared between the processes that run your flows, and it is cleared when one of them restarts — 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 and association types), 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.
association_labelsnoList of association labels. Keeps only the records whose association carries one of them. Default: absent, no filter. See Filtering by association label.
association_type_idsnoList of association type ids. Keeps only the records whose association carries one of them. Default: absent, no filter. Cannot be combined with association_labels.
include_association_typesnotrue adds association_labels and association_type_ids to each returned record. Default false. A spec with a filter adds them on its own.

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: ...}.

Filtering by association label

When the same object type is associated to the parent with different labels (two contact roles on a deal, for example), an association spec can ask for the records of one label only, and every returned record says which labels link it to the parent.

{"id": "enrich", "action": "find_associated", "target": "hubspot", "entity": "deal", "source": "$.payload.hs_object_id", "associations": [ {"entity": "contact", "as": "decision_makers", "association_labels": ["Decision maker"], "properties": ["email"]}, {"entity": "contact", "as": "billing", "cardinality": "one", "association_type_ids": [41], "properties": ["email"]} ]}
  • association_labels matches the label as it is written on the HubSpot portal, ignoring letter case and surrounding spaces. With several labels in the list, a record carrying any of them is kept.
  • association_type_ids matches the numeric type id of the association. With several ids in the list, a record carrying any of them is kept. A type id is matched whatever its category, HubSpot-defined or user-defined.
  • The unlabeled association is on every pair. HubSpot lists the default unlabeled type next to the labels of a labeled association (the record below carries [3, 36]), so filtering on the unlabeled type id returns every associated record, labeled or not. Neither filter selects the records that have no label at all: to tell them apart, read association_labels on each record, which is empty for those.
  • A spec takes one of the two keys, not both. To read two labels under two output keys, write two specs for the same object, as in the example.
  • A record associated with several labels is returned once, by every spec whose filter it passes.
  • With cardinality: one the result is the first record that passes the filter, in the order HubSpot lists the associations; null when none does.
  • A filter that matches nothing yields [] (or null), which when: {found: ...} reads as not-found. A label that does not exist on the portal is not an error: it matches nothing.

Each record of a spec that filters, or that sets include_association_types: true, carries two extra fields next to id and properties:

FieldContent
association_labelsThe labels of the association between the parent and this record, e.g. ["Decision maker"]. Empty when the association has no label.
association_type_idsThe type ids of that association, e.g. [3, 36]: the default unlabeled type (3 here) and one per label.
{"decision_makers": [ {"id": "102", "properties": {"email": "ada@example.com"}, "association_labels": ["Decision maker"], "association_type_ids": [3, 36]} ]}

Read them downstream like any other field: $enrich.billing.association_labels.

A spec without these three keys returns exactly the records it always did, with no extra field. The filter reads the association types HubSpot already returns with the list of associated ids, so it adds no request; records that do not pass are not hydrated, so a filtered spec issues fewer reads than an unfiltered one. The shape of the three keys is validated when the draft is saved and again on publish (an empty list, a label that is not a string, a type id that is not a positive integer, both filters on one spec, or a non-boolean include_association_types are rejected). In a dry run there is no real association to read: the one placeholder record passes any filter and shows the values the spec filters on.

Only the first page of associations HubSpot returns for the parent is read, with or without a filter.

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

source is a HubSpot object id, NOT an external id. 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 permanently (no retry), 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: 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).

upsert, create and update look for the record in the same way: the connector’s own mapping for the external id, then a search on the entity’s identity property, then the entity’s fallback lookups. They differ in what they do with the answer:

ActionRecord foundNo record found
upsertUpdates it.Creates it.
updateUpdates it.Does not create it. The flow stops as a skip with skip_kind update_record_not_found.
createDoes not modify it. The step is skipped with skip_kind create_record_exists and the flow goes on.Creates it.

Neither skip is a failure: the event is not retried and does not count against the flow’s health (see Why a record was skipped). They differ in what happens to the rest of the flow.

update with no record stops the flow, like every other skip that stops one:

  • The steps after it do not run, a notify included, and the step produces no output for a later step to read.
  • The event is skipped, with the reason on the last entry of the flow’s step_trace. When an earlier step of the flow, or another flow of the same event, already wrote, the event stays success and the trace still carries the reason.

create on an existing record skips only itself. An event can be processed more than once (a later step failed and the event was retried, or the same event was delivered twice), and every run starts from the first step. On the second run a create finds the record its first run created, so the step stands in for the create that already happened:

  • Its step_trace entry reads skipped, with skip_kind create_record_exists, the reason and the id of the existing record.
  • The steps after it run. The step’s output is the existing record, so a later step reads its id exactly as it would after a create. The properties of that output are the values this event mapped, not a read of the record on HubSpot.
  • Its inline associations are applied when the record is one the connector already has a mapping for, which is the case of a retried event. When the record was found another way (by the search, by a fallback lookup, or named by HubSpot when it refused the create) they are not applied, and the trace entry says so.
  • The event is skipped with that skip_kind only when no step of the flow wrote anything. As soon as another step wrote, or the step applied its associations, the event has the status those writes give it and the kind stays on the step’s trace entry.

Use upsert when the flow should write in both cases. Use update for an event that must never create a record (a status change on a record another flow owns), and create for one that must never overwrite a record that is already there.

Details worth knowing:

  • update on a record the connector had mapped and that has since been deleted on HubSpot does not recreate it, also when the entity declares on_target_deleted: recreate: recreating is a create. With the default policy the event is quarantined, as for upsert. Replaying the event after releasing the quarantine does not recreate the record either: an update flow is skipped again. To bring the record back, send the event through a flow that uses upsert.
  • When the mapped record is gone, update first searches HubSpot for another record carrying the same identity value. If it finds one, the connector’s mapping moves to that record before the step updates it; if that record turns out to be gone too, the step is skipped and the mapping stays on the record the search found.
  • create does not detect a record deleted on HubSpot. It takes the connector’s mapping at its word: an external id that is already mapped is skipped without reading HubSpot, and it keeps being skipped for as long as the mapping exists. Steps after it then run against an id that no longer exists. An upsert or update step on the same entity does detect the deletion and applies the entity’s on_target_deleted policy, so use one of those for records that can be deleted on HubSpot while the source keeps sending them. To repair one record that is already in this state, remove its identity mapping with plugsync connector identity remove <connector> --entity <name> --external-id <id> (or DELETE /api/connectors/{connector_id}/identity-mappings/record): the next create event for that external id no longer finds a mapping, looks the record up from scratch and, finding none, creates it. See Repairing one identity mapping.
  • The same rule covers mappings loaded ahead of time: every external id that was imported or pre-loaded into the connector’s mappings makes create skip, because the connector already knows that record.
  • A create that finds the record by the search or by a fallback lookup does not save a mapping for it (it did not write the record), so the next event for the same external id searches again and is skipped again.
  • When HubSpot refuses the create because the record already exists (a duplicate on a unique property), create is skipped in the same way and leaves that record untouched. This is the one case where the step called HubSpot to write before skipping: the create request that was refused.
  • Before looking for the record, upsert, update and create make sure that the values they are about to send to a dropdown property exist as options on HubSpot, and add the ones that are missing. A step that ends up skipped can therefore have added an option to a property.
  • A dry run cannot search HubSpot. It rehearses an update step against an assumed existing record and marks the step with simulated_existing_record, so the projection stays visible. A create step is rehearsed as a create.
  • The batch actions (create_many, update_many, upsert_many) are not affected: they report per item, as described below.
FieldRequiredNotes
targetyes"hubspot" for all four. upsert/create/update accept nothing else - use post instead for external writes; delete also accepts a REST destination name.
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?, association_category?} entries - see “Associations” below.
fieldsnoupsert only (and upsert_many). Allowlist of the entity’s field_mappings targets to write; the other static targets are left out of this write. See “Writing part of the mapping (fields)” 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. upsert/create/ update/delete write the entity’s hubspot schema field_mappings projection (see entities.md), never step.data - unlike update_by_id, which genuinely reads data as its request body (all of it, or only the mapped properties with mapped_properties_only). 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"}

Writing part of the mapping (fields)

By default an upsert writes every property the entity’s hubspot schema maps. When an event is authoritative for only some of them (a status update that must not overwrite the address, for example), fields narrows the write to a list of mapping targets:

{"id": "upsert_status", "action": "upsert", "target": "hubspot", "entity": "company", "fields": ["customer_code", "lifecyclestage"]}
  • A mapping with a static target outside the list is not written. A target_template mapping is never filtered by fields.
  • The identity property is always written, and the list must name it.
  • fields is accepted on upsert, upsert_many and post only. On upsert and upsert_many it requires target: "hubspot". On post it filters the body projected from the target’s schema, needs entity, and the identity property is not added (see Body projection).

The list is checked when you save the draft, against the entity in the same config. The step is rejected when:

ConditionMessage
fields on another action`fields` is only supported on upsert/upsert_many/post steps, not on action 'x'
upsert/upsert_many with a target other than hubspot`fields` requires target 'hubspot' on upsert/upsert_many
post without entity`fields` on a post step requires `entity`: it filters the target schema's field_mappings projection ...
An empty list`fields` must be a non-empty list of field_mappings targets; omit it to project the full entity mapping
The entity is not in the configflow 'f' step 's': `fields` declared but entity 'e' does not exist in this config
The entity has no schema to project (hubspot, or the post target)flow 'f' step 's': `fields` declared but entity 'e' has no 'hubspot' schema to project against
An entry matches no static target of that schema (nor identity.property)flow 'f' step 's': `fields` entries ['x'] do not match any field_mappings target of entity 'e' (known targets: [...])
upsert/upsert_many whose list leaves out the identity propertyflow 'f' step 's': `fields` must include the identity property 'p' of entity 'e' - it is always written, so an allowlist that excludes it is a misconfiguration

Unlike most references to an entity, which a draft may leave dangling until publish, a fields list needs its entity to exist in the same config already when you save the draft.

delete against a REST destination

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": "orbit-crm", "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: it is not retried, and the failure shows on the event with the error attached to the step. 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 - the step output is {"deleted": false, "hubspot_id": ..., "reason": "no_identity_mapping"} and there is 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 instead of failing forever. Since the record was never actually seen at that url, the step output sets already_absent: true - filter on it to audit mappings discarded without confirmation.
5xx, 429Transient -> retried (429 honours the target’s Retry-After). The mapping is kept, so the retry can still resolve the id.
other 4xxPermanent failure, no retry. The mapping is kept.

The mapping is dropped only once the target confirms - the same status classification as every other outbound call (post).

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

Associations

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.
association_categorynoCategory of association_type_id: HUBSPOT_DEFINED, USER_DEFINED or INTEGRATOR_DEFINED. Requires association_type_id in the same entry. Default: absent, the category is read from the portal (see Association category). The preflight does not check the entries of an associations list against the portal.

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} ]}

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).

The identity lives on HubSpot, so the step needs no target. It accepts target: "hubspot" for symmetry with the other HubSpot actions; any other value is rejected when you save the draft with promote_identity currently only supports target 'hubspot' (or omit target entirely), got 'x'.

FieldRequiredNotes
entityyesEntity whose HubSpot identity property is renamed.
targetno"hubspot", or omitted (same effect).
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, the same live read conflict detection uses). 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 (transient -> retried, permanent -> the event fails). 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.

$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 (also rendered in the dashboard action 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 (form submission event from an e-commerce site, 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 destination: an event source with an outbound config, which provides the base URL and the authentication (see EventSources). Also the entity schema key the step reads (identity, and body projection when body is omitted).
entitynoEntity whose schemas.<target> schema the step uses. When that schema declares identity, entity switches the step to the identity-aware round trip (below), with or without body. When body is omitted, its field_mappings also project the body.
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, sent as written and merged over the target’s authentication headers (a header named in both takes the step’s value).
fieldsnoAllowlist of field_mappings targets to project into the body. Requires entity; only meaningful when body is omitted. See upsert’s fields for the checks made when you save the draft.
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 -> acme, explicit body):

{"id": "push_acme", "action": "post", "target": "acme_outbound", "entity": "company", "url": "/api/companies/upsert", "body": "$.payload.object.properties"}

If the step names an entity whose schema for target declares identity (an external-id mapping keyed on the resolved HubSpot object), post switches to an identity-aware round trip instead of a plain POST, whether or not the step also declares body (an explicit body is then the body of the create or update request): no mapping yet -> POST url; mapping exists -> update_method ("PUT" or "PATCH", default PUT) against update_url (default: url + / + the external id). This is the only supported path for external create-vs-update semantics - upsert does not support external targets (see above). Body projection (below) applies equally to this identity-aware round trip.

Without that identity block the step stays a plain POST, and update_method / update_url have no effect: they are not silently ignored settings, they simply never come into play, because nothing tells the step whether the record already exists on the target. The block also needs identity_response_path, which is how the create response’s id gets written back into the mapping.

Response: any 2xx is a delivery

A plain post succeeds on any 2xx, whatever the response body holds. The step is not retried and the request is not sent again.

The step output always has the same two keys, status_code and body, which later steps read as $<step_id>.status_code and $<step_id>.body.<field>:

Target answersbody in the step outputStep trace
2xx with a JSON bodythe parsed JSONsuccess
204, or any 2xx with an empty body{}success
2xx with a body that is not JSON (text/plain, HTML, …){}success, plus the warning post_response_not_json

A reference into an empty body, such as $push_acme.body.id after a 204, resolves to nothing rather than failing the step that reads it. If a later step needs a value from the response, guard it with a when condition.

The post_response_not_json warning carries the step id, the target, the status code and the response content_type. It never carries the response body. It tells you the target did answer with something, just not something $<step_id>.body can read: usually an endpoint that confirms with a plain OK, or a proxy answering in place of the API.

Only a non-2xx status fails the step: 429 and 5xx are retried, any other 4xx is not.

The identity-aware round trip is stricter on a create: it must read the new record’s id from the response through identity_response_path, so a create answered without a usable body fails instead of leaving the record unmapped.

Body projection

body is FALLBACK-ONLY: when it is set (as a $-ref string or an inline dict), it always wins, with zero merge against a projection. Omit body (and set entity) to have the body built from entities.<entity>.schemas.<target>.field_mappings instead, via the same projection rule the hubspot upsert path uses (it is schema-agnostic). Every mapping on the schema is applied (target is a literal key name in the outbound body; transform, per mapping, runs exactly like it does for a hubspot upsert); fields restricts that to a subset, same allowlist mechanism as upsert/upsert_many’s fields. data has no bearing on this gate either way: post does not declare it, and only body presence and entity decide.

Unlike upsert/upsert_many, a post fields allowlist is not required to include the target schema’s identity property. Upsert force-includes the identity property regardless of fields, because it is always written on create/update; post’s projection has no such force-include — the schema’s identity block (when present) only drives the create-vs-update HTTP routing described above, not a written property, so omitting it from fields is valid and the property genuinely does not appear in the outbound body.

notify (below) sends over the same HTTP path as post, but is explicitly excluded from this whole body-projection fallback: a notify step accepts no entity/fields, and always sends whatever body it declares (or none) — unaffected by this section.

{ "entities": [{ "name": "company", "schemas": { "orbit-crm": { "field_mappings": [ {"source": "$.canonical.name", "target": "name"} ] } } }], "flows": [{ "steps": [ {"id": "push_orbit_crm", "action": "post", "target": "orbit-crm", "entity": "company", "url": "/companies"} ] }] }

The push_orbit_crm step above sends {"name": <resolved value>} as its body — no hand-written body template. The projection is a real, checked consumer of those field_mappings: preflight no longer reports them as unconsumed, and flags unknown_post_projection_schema when the target names no schema on the entity. See entities.md for the full field_mappings-consumer reference.

Projecting a list into the body (each)

A target that wants a nested collection in its own field names — an offer carrying its contacts, an order carrying its lines — needs one key of the body to be a LIST of objects, each shaped by a mapping. An each node inside an explicit body does exactly that:

{"id": "push_offer", "action": "post", "target": "orbit-crm", "url": "/offers", "body": { "token": "$login.body.token", "data": { "offer": {"code": "$.payload.object.properties.offer_code"}, "company": {"code": "$find.company.properties.orbit_company_code"}, "contact": {"each": "$find.contacts", "entity": "contact", "schema": "orbit-crm"} } }}

$find.contacts is a prior step’s output — typically a find_associated entry with cardinality: "many", so a list of hydrated HubSpot objects. The node replaces itself with one projected object per element, in order:

{ "entities": [{ "name": "contact", "schemas": { "orbit-crm": { "field_mappings": [ {"source": "$._item.id", "target": "hubspot_id"}, {"source": "$._item.properties.orbit_contact_code", "target": "code", "default": "0"}, {"source": "$._item.properties.firstname", "target": "name", "default": "Missing"}, {"source": "$._item.properties.email", "target": "email"} ] } } }] }

Rules:

AspectBehaviour
Node shapeeach (a $-ref), entity, schema all required; fields optional.
Element scopeEach element is bound as both $._item.* and $.payload.*, so a mapping written for a per-record projection reads the element. The entity’s payload_root does not apply: the element is already its own root.
Projection ruleThe same rule the body-less projection uses: every mapping on the schema applies (target is a literal key in the projected object), transform and default included; fields restricts it to a subset of static targets.
each resolves to a listOne projected object per element, in the ref’s order.
each resolves to null, or to []The key is sent as []. “No associated records” is a state of the event, not an error.
each resolves to anything elseThe step fails permanently, with the ref named in the error: a target must never silently receive a scalar where it expects a collection.
PlacementAny depth of the body, alongside ordinary $... leaves, and on the identity-aware round trip too.

Full form or nothing. each is a reserved key in a step body: a dict carrying each without entity and schema is rejected with a 422 when you save the draft, never sent to the target as a literal {"each": ...} object. Unknown keys inside the node are rejected for the same reason (a typo’d fields would otherwise be dropped in silence), and fields entries are checked against the projected schema’s own target names, so a typo there is a 422 too rather than a list of empty objects on the wire. A target (or a HubSpot property) that genuinely expects a field named each cannot be fed one from a step: the key is reserved in a step’s body, data, params and headers alike, since only the post body projects it and the other three would send it verbatim. If an existing step already uses a literal each key in any of those four, rename it — it is now read as a projection node, so the step fails instead of sending it, and the next draft save of that step is rejected until the key is renamed.

Out of scope, all rejected when you save the draft:

  • notify — it never projects a body (see its own section below), so an each node there would go out verbatim.
  • Nested each (a list inside an element of a list): the node’s keys are closed, so it cannot carry another body.
  • each outside the body: a node in headers, params or data is rejected, because those resolve $-refs and nothing else. url (a string) and when (a condition) cannot hold a node in the first place.

The node’s references are checked the way the step’s own refs are, and publish rejects three of them. A node naming an entity this config does not declare, a schema that entity does not declare, or an each ref whose root is neither a static root nor a step of the same flow, is a 422 when you publish: the first two fail the step permanently on every event, the third projects an empty list on every event in silence, and none of them is worth discovering in production. The draft still saves — you may write the node before the entity it projects — so the rejection comes at publish, not at save.

Preflight explains the same three first, with a typo suggestion, so you see them in the preview before you publish: each_unresolvable_ref, unknown_each_projection_entity, unknown_each_projection_schema. A schema that exists but declares no field_mappings (so every element would project to {}) stays the warning each_projects_empty_schema and does not block publish: an empty schema is a config still being written, not a broken reference. The schema a node projects counts as a real consumer of its field_mappings, so it is never reported as unconsumed.

URL path templating

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": "orbit-crm", "entity": "company", "url": "/companies", "body": "$.canonical.company"}, {"id": "create_lead", "action": "post", "target": "orbit-crm", "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 the field-mapping resolver understands works ($.payload.*, $.canonical.*, $._item.*, $.external_id, $.event_type, $.occurred_at, $<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 fails the step permanently before the callSubstituting nothing yields /companies//users, which on many APIs is a 200 against the collection endpoint. A failed event beats a silent write to the wrong record.
$.credential.* is refusedURLs are recorded and echoed in outbound error details, so a secret in a path leaks by construction. Put it in body or headers, which are never recorded.
{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: a destination resolved like a post target (base URL and authentication headers).
urlnoRequired at runtime for delivery. Supports {$...} path refs like post - but since a notify never fails its flow, an unresolved ref surfaces as notify_failed in the step output rather than as a flow error.
bodynoJSON body: a $... ref, or an inline object whose $... leaves are resolved like a post body ($.credential.* reads the target’s own credential). Without body the request carries no body: a notify never projects one from an entity. An each node is refused in a notify body when you save the draft; use a post step for that.
headersnodict of headers, sent as written and merged over the target’s authentication headers (a header named in both takes the step’s value).

Missing field error: notify step requires target.

Delivery: best effort, with a bounded retry

A notify never fails, retries or discards the event whose sync already happened. That rule is unchanged - but “best effort” does not mean “one attempt”:

  • Rate limiting (429) and server errors (5xx) are retried inside the step, up to 3 attempts and 60 seconds. That budget is wall-clock: the time the target takes to answer counts against it just like the waiting does.
  • A 429’s Retry-After is honored as long as the wait fits what is left of the budget; a longer one is refused rather than truncated, and the step gives up straight away instead of holding the event. A 5xx uses a short fixed backoff instead (one second, then two): Retry-After is read on 429 only.
  • Client errors (4xx) are never retried. A rejected notification is broken rather than delayed, and retrying it only adds load to a target that is already answering.
  • Network errors and timeouts are not retried either, exactly like post: the step gives up on the first one.
  • Any 2xx counts as delivered, including one whose body is not JSON - a webhook answering 200 text/plain has received the notification.

Only the send is repeated. Everything the step does before it (in particular the freshness check that freshness_check adds) happens once, whatever the target answers.

When the budget runs out the step gives up and the flow carries on. What you see then:

  • the record’s sync-event status is partial_success, not success: the sync landed, the notification did not. The error detail names the target and the status it answered with - or, when no response ever came back, the class of the error. Never the URL, which can carry a credential in a path segment;
  • the step’s entry in the event’s step trace reads notify_failed, and the record carries a warning with the same facts as fields (target, status, attempts);
  • for an event submitted through the Event API, that entity counts as a partial: GET /api/v1/events/{event_id}/status answers partial_failure, not completed. Part of what you asked for did not happen;
  • the row is replayable like any other partial_success. A replay re-runs the flow - its writes are idempotent upserts - and retries the notification with it.

A notification that is only late, not lost, therefore costs nothing: it is delivered on the retry and the record stays success.

plugin

Imperative escape hatch: run your own plugin code for logic the manifest can’t express (HTTP calls to third systems, stateful branching). 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.

FieldRequiredNotes
pluginyesJSON object. Authored as {"logical_name": "<plugin-name>"} plus an optional config object of static settings and an optional writes flag (see below). function_arn, version and capabilities are resolved and embedded automatically at publish time — never author them by hand. See promotion.md.

A step must declare plugin.logical_name; without it, saving the draft fails with plugin step requires plugin.logical_name. A step still carrying the retired module/function pair gets the same error plus a migration hint pointing at plugsync plugin init/push/promote.

plugin.config carries two keys the runtime reads itself, next to your own settings: store_keys (the store entries to pre-load) and trace_output (the response keys to report in the sync event’s step_trace, none by default). Both are described in the payload contract.

Declaring that a plugin step does not write

By default a plugin step counts as a write: the runtime cannot see what your code did on the other side, so an event whose flow ran a plugin step successfully closes success and is counted, like an event that wrote to HubSpot. A plugin that only checks or reads (a validation, a lookup feeding a later step) can declare otherwise with writes: false:

{"id": "validate", "action": "plugin", "plugin": {"logical_name": "iban-check", "writes": false}}
  • An event whose flow ran only steps that do not write (writes: false plugin steps, transform, find, skip and the other read-only actions) closes skipped, not success, and is not counted. The step itself still reads success in the sync event’s step_trace, with "writes": false on its entry so the reason for the skipped outcome is visible.
  • As soon as another step of the same flow writes (an upsert, an association, a plugin step without writes: false), the event is counted as usual.
  • A failed outcome declared by the plugin still closes the event failed: writes: false changes what counts as a write, not what counts as a failure.
  • The declaration belongs to the step, not to the plugin’s response: the same plugin can write in one flow and only validate in another.
  • writes must be true or false; any other value is rejected when the draft is saved with plugin.writes must be true or false. Leaving it out is the same as true.

The three embedded fields are the ones you see, and only in the published config: function_arn and version pin the exact plugin build this version runs (a test connector resolves to the current dev build, a live connector to the promoted version), and capabilities is the allow-list that build was promoted with. They appear in the published config returned by GET /api/connectors/{id}/versions/active, and GET /api/config-surface lists them under plugin_embedded_fields as server-materialized — which is why plugsync diff and the dashboard’s draft-changes banner do not report them as your edits.

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.
outputnoA plain key name (not a JSONPath) under which the step’s result is stored instead of the step id. Later steps read it as $<output>, e.g. "output": "batch_out" then $batch_out.
reconcilenoupsert_many only. When true, the records the identity map does not know are first looked up in HubSpot with one batched search on the entity’s identity.property; a record found there is updated and mapped instead of created again. Off by default: without it an unmapped record is created.
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": ["fooshop_externalobject_id"]} ]}, {"id": "wipe_stale", "action": "delete_many", "target": "hubspot", "entity": "line_item", "items": "$find_stale.stale", "external_id_ref": "$._item.properties.fooshop_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.
chunk_sizenoPairs sent per HubSpot batch call: an integer from 1 to 100, default 100 (HubSpot’s batch page size). A larger value is rejected when you save the draft.
association_type_idnoStep-level default type id for the pairs that carry no type_id of their own.
association_categorynoCategory of the step-level association_type_id (HUBSPOT_DEFINED, USER_DEFINED or INTEGRATOR_DEFINED), for the pairs that carry neither a type_id nor a category of their own (a pair that repeats the step’s id in its own type_id counts as carrying one: set category on that pair). Requires association_type_id. Default: absent, see Association category. The preflight does not check associate_many pairs against the portal.

Missing field error: associate_many step requires items.

Example:

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

query

Reads every HubSpot record of an entity that matches filters, following the pages to the last one, optionally restricted to one calendar day of a date-time property (window). Output {results, total, window}. Built for scheduled exports: the full schema, the day window and a worked example are in Scheduled exports.

FieldRequiredDescription
targetyesMust be hubspot.
entityyesEntity whose hubspot schema gives the object type.
filtersnoAND of {property, operator, value}. Operators: EQ, NEQ, LT, LTE, GT, GTE, CONTAINS_TOKEN, NOT_CONTAINS_TOKEN (one value), IN, NOT_IN (a list), HAS_PROPERTY, NOT_HAS_PROPERTY (no value). At most 6, a window counting as 2.
propertiesnoHubSpot property names to read.
windowno{property, timezone?, offset_days?}: the day offset_days (default -1, from -366 to 0) from the tick’s scheduled time (the event time for any other trigger), midnight to midnight in timezone (IANA, default UTC).
max_recordsno1 to 10000 (default 10000). A larger result fails the step instead of being truncated, and so does any result of 10000 or more, where HubSpot’s count stops.

export_file

Renders a list of records as CSV and writes it to plugsync-managed storage, in the connector’s own folder, replacing a file already at that path. Files are kept 30 days from their last write and deleted with their connector, see How long the files are kept. Output {path, rows, size_bytes}. Full schema in Scheduled exports; the files are downloaded from the connector’s Exports section in the dashboard or with the API, see Downloading the files.

FieldRequiredDescription
rowsyesAn each node ({each, entity, schema, fields?}, as in a post body) or a $-ref to a list of objects. A $<step> ref must name an earlier step of the flow; a ref that resolves to nothing fails the step (an empty list writes the header only).
keyyesRelative file path; {$...} placeholders must resolve to a plain name (letters, digits, ., _, -). No leading or trailing /, no .., no backslash or non-printable character (such as a non-breaking or zero-width space), no $.credential.
columnswith a $-ref rowsField names or {field, header}; a dotted field reads a nested value. With an each node, defaults to the schema’s mapping targets.
formatnocsv (the only format).
delimiternoOne character, default ,; not a quote or a line break.
headernoWrite the header row (default true).
escape_formulasnoPrefix ' to a value starting with =, +, -, @, a tab or a carriage return, so a spreadsheet shows it as text (default false).

skip

Stops the flow. When a skip step runs, the remaining steps of the flow do not run and the flow ends skipped, with skip_kind explicit_skip_step (see Why a record was skipped). Steps before it have already run, and what they wrote stays written. Paired with when to express “stop here if …”:

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

The action validator imposes no required fields on skip. Without a when condition it stops every event, so it is almost always paired with one; to switch off a single step instead, put the when on that step.

when conditions

The optional when field can carry any of the eight WhenCondition variants (the same set GET /api/config-surface publishes):

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