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.
| Action | What it does |
|---|---|
transform | Project the event into $.canonical through a schema’s field mappings. |
find | Look one record up (HubSpot search, or a GET on a REST destination). |
find_associated | Read a record’s associated objects and hydrate them. |
upsert, create, update, delete | Write one record, resolving identity from the entity’s identity property. |
update_by_id | PATCH a HubSpot record by an id you already hold. |
associate | Associate two records resolved by external id (single pair, or 1->N), optionally reconciling the whole set with replace. |
dissociate | Remove an association between two records resolved by external id (single pair, or 1->N). |
promote_identity | Move an identity mapping from one external id to another. |
upload_file | Attach a file to a deal as a Note. |
delete_attachments | Archive the Note(s) carrying a deal’s attachment. |
submit_form | Submit a HubSpot form. |
post | Send an HTTP request to a destination. |
notify | Best-effort webhook, failures never fail the flow. |
plugin | Run your own code for logic the DSL does not cover. |
query | Read every HubSpot record matching filters, optionally one calendar day of them. |
export_file | Write a list of records as a CSV file to plugsync-managed storage. |
skip | Abort the flow when reached. |
upsert_many, create_many, update_many, delete_many | Batch writes (v3.1). |
associate_many | Batch 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 aschunk_sizeon a singleupsert.
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.
| Field | Required | Description |
|---|---|---|
target | yes | Must be hubspot. |
from_entity | yes | Entity name of the source endpoint (used for the identity lookup + object type). |
from_external_id | yes | $... ref resolving to the source endpoint’s external id. |
to_entity | yes | Entity name of the target endpoint. |
to_external_id | yes | $... ref resolving to the target endpoint’s external id. With items, resolved per element against $._item. |
items | no | Array form (1->N): $... ref to a list; the from record is associated to every element. |
chunk_size | no | Array form batch page size (default 100, max 100). |
association_type_id | no | Explicit HubSpot association type id (e.g. 279 contact<->company). Omitted: auto-discovery. |
association_category | no | Category 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). |
replace | no | Requires 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_category | Category 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_DEFINED | Exactly 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_categorywithoutassociation_type_idis rejected: without an id the type is discovered, category included;- on a step of another action it is rejected like
association_type_idis.
The preflight (GET /api/connectors/{connector_id}/preflight, the
Review-changes drawer, plugsync validate) checks associate and
dissociate steps against the portal:
| Finding | Severity | When |
|---|---|---|
ambiguous_association_type_id | warning | The 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_mismatch | error | The 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:
| Case | Why nothing is removed |
|---|---|
from_external_id resolved to nothing | Without the source record there is no association set to reconcile. |
| An element of the list has no identity mapping yet | That 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_id | A 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.
| Field | Required | Description |
|---|---|---|
target | yes | Must be hubspot. |
from_entity | yes | Entity name of the source endpoint (used for the identity lookup + object type). |
from_external_id | yes | $... ref resolving to the source endpoint’s external id. |
to_entity | yes | Entity name of the target endpoint. |
to_external_id | yes | $... ref resolving to the target endpoint’s external id. With items, resolved per element against $._item. |
items | no | Array form (1->N): $... ref to a list; the association to every element is removed. |
association_type_id | no | Removes 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_category | no | Category 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.
| Field | Required | Description |
|---|---|---|
target | yes | Must be hubspot. |
entity | yes | Entity name (used to resolve the HubSpot object type from the schema). |
object_id | yes | Required field: a $... ref to the HubSpot numeric id to update. If it resolves to None/empty at runtime, the step is a graceful no-op (see below). |
data | yes | JSONPath or inline dict of properties to patch. |
items | no | $... ref to a list; enables array (1->N) mode (see below). |
mapped_properties_only | no | true: 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
targetof each of the entity’shubspotfield 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:FirstNameis notfirstname. The mappings’sourceandtransformare not applied: a key is sent with the valuedatacarries, under the namedatagives it. A payload key named differently from the HubSpot property it feeds is dropped; to rename or transform, keepdataas 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
dataobject (an owner, a stage, a fixed value) is dropped like any other key unless the entity maps it. - A mapping whose
targetstarts with$.writes the canonical side, not HubSpot, so it adds no property to the set. A mapping with atarget_templatenames its property per event, so its properties are not in the set either. - It applies to an inline
dataobject too, and to every element of the array form. datamust 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 isskipped_no_mapped_properties, notsuccess. - The step carries a warning with code
no_mapped_propertiesand adropped_keyslist: 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
skippedwithskip_kindno_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 asuccessand 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_fieldnames the base64 field inside the eventdata(a dotted path such as"linkfile", not a$.payload.*reference). Without it the field reaches the flow as a raw string, andupload_fileis a no-op traced asskipped_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;numdocalone is whatdelete_attachmentsmatches on.- The file is sent on
POST /api/v1/eventswith thedatashape, as a bare base64 string. A file inside anentitiesentry or a bulk import row is not set aside. - The HubSpot connection grants the
files.readandfiles.writescopes. 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).
| Field | Required | Description |
|---|---|---|
file_ref | yes | $... ref to the claim-check dict the ingest boundary stamped (e.g. $.payload.linkfile). A missing/non-dict ref is a no-op traced as skipped_missing_ref. |
object_id | yes | $... ref to the deal’s HubSpot id (e.g. $upsert_deal.id from a prior upsert). A missing id is a no-op traced as skipped_missing_ref. |
note_body | no | Template for the Note body. The single token {filename} is substituted. Default: Allegato preventivo aggiornato {filename}. |
folder_path | no | HubSpot Files folder (auto-created). Default: /deals_attachments. |
Missing field error: upload_file step requires file_ref / upload_file step requires object_id.
Idempotent 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:
- Ingest.
settings.file_fieldpoints atlinkfile: the base64 string is set aside andlinkfilebecomes{"s3_key": "...", "filename": "9000_V2.pdf", "numdoc": "9000", "version": "V2"}.numdocis9000/V2split on/, first part;versionis the second part (V0when the number has no/). stamp_offer_idxruns theoffer_indexschema, a computation-only schema whose single mapping writes to canonical:9000/V2->V2->2->2->3->3(clamped to 1..5), so$.canonical.offer_idxis3. The+1makes the first revision (V0) land indata_offerta_1; the clamp keeps later revisions inside the five provisioned slots. Seetransform.upsert_dealwrites the deal.deal_last_offer_datereads$.occurred_at, the event’stimestamp(the ingest instant when the sender omits it), cut to2026-09-11byiso_to_date. Thetarget_templateresolves todata_offerta_3and receives2026-09-10. Both are HubSpotdateproperties:hubspot_type/hubspot_field_typemake publish create them as dates, andprovision_nameslists every name the template can produce so all five slots exist before the first event. See computed targets and field-mapping roots.upload_offer_pdfuploads9000_V2.pdf, attaches it to the deal with the noteUpdated offer 9000_V2.pdf, and removes the staged file. Thewhen: existsguard skips the step on revisions sent without a file and on a file dropped at ingest as invalid base64 (its field isnull). 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).
| Field | Required | Description |
|---|---|---|
file_ref | yes | $... ref to the claim-check dict {s3_key, filename, numdoc, version} (e.g. $.payload.linkfile). The numdoc is the match key. A missing/non-dict ref falls back to numdoc_ref. |
object_id | yes | $... ref to the deal’s HubSpot id (e.g. $upsert_deal.id from a prior upsert). A missing id is a no-op traced as skipped_missing_ref. |
numdoc_ref | no | Fallback $... ref for the {numdoc} match key when the event carries no claim-check ref (e.g. $.payload.deal.numerodoc on a file-less delete event). Used only when file_ref does not yield a numdoc. |
numdoc_transform | no | Transform pipeline refining numdoc_ref, e.g. [{"name": "split", "args": {"sep": "/", "index": 0}}] — the same shape as the ingest file_numdoc_transform setting. Requires numdoc_ref. |
Missing field error: delete_attachments step requires object_id /
delete_attachments step requires file_ref.
Idempotent: a deal with no matching note is a quiet no-op. A run where the
numdoc (or the deal id) cannot be resolved at all does nothing and records
skipped_missing_ref — not success — as the step’s trace status. The step
output is
{"operation": "delete_attachments", "deal_id", "numdoc", "archived_note_ids"}.
Example (the numdoc_ref pair makes the cleanup work on delete events that
carry no file):
{"id": "cleanup_offer_pdf", "action": "delete_attachments",
"file_ref": "$.payload.linkfile", "object_id": "$upsert_deal.id",
"numdoc_ref": "$.payload.deal.numerodoc",
"numdoc_transform": [{"name": "split", "args": {"sep": "/", "index": 0}}]}transform
Reshapes data from one schema into another, typically inbound source into
canonical. Reads from_schema’s field_mappings and, for every mapping
whose target is a $.canonical.<key> path, applies its transform (if
any) and its resolve marker (if any — owner_email_to_id /
owner_id_to_email / owner_ids_to_emails, the same owner-directory lookup
the hubspot projection uses — see
entities.md for the list-aware multi-select
marker) and writes the result into to_schema (ctx.canonical when
to_schema is "canonical"). A mapping whose target is NOT a
$.canonical. path is silently dropped by this filter — point target at
$.canonical.<key>, never a plain property name, on any schema you intend
to read via from_schema.
| Field | Required | Notes |
|---|---|---|
entity | yes | The entity name being transformed. |
to_schema | yes | Target schema key (usually canonical). |
from_schema | yes | Source schema whose field_mappings project into to_schema. Required — there is no “read from inbound payload” fallback. 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.
| Field | Required | Notes |
|---|---|---|
target | yes | hubspot, or a REST destination (event source) name. |
entity | when target is hubspot | Drives the HubSpot object type. |
by | yes | One condition, {"property": "<hubspot property, or REST result item field>", "value": "<$-ref>"} with an optional operator, or several in OR: {"any": [<condition>, ...]}. See find conditions. |
properties | no | HubSpot 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. |
url | when target is not hubspot | Path appended to the destination’s outbound base_url. Supports {$...} path refs, the path-side counterpart of params. |
results_path | no | REST only: jsonpath to the candidate list in the GET response body; omit when the body IS the list. |
params | no | REST only: GET query params dict; values may be $... refs (lookup chains). |
headers | no | REST only: extra headers merged over the destination’s resolved auth headers. |
cache_ttl_seconds | no | REST only: in-process cache TTL (seconds); a second identical lookup within the TTL skips the HTTP call. |
Missing field errors: find step requires target, find step requires by,
find step requires entity when target is 'hubspot', find step requires url when target is not 'hubspot'.
find conditions
A condition is {"property": ..., "value": ..., "operator": ...}. operator
is optional:
operator | Matches |
|---|---|
EQ (default) | The property equals the value. |
CONTAINS_TOKEN | One 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.
findstill returns one record. On HubSpot, when several records match, the one with the lowest record id is returned. The order of the conditions insideanysets 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 successivefindsteps guarded bywhen: {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
valueresolves 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 aCONTAINS_TOKENcondition 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_TOKENmatches whole entries only. Withada@example.com, a record holdinggrada@example.comis 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 ananylist where anEQcondition already matched a record with a lower id than all 100: that record is returned.- Returned properties with
CONTAINS_TOKENon HubSpot. The matched object carries the properties listed inpropertiesplus the ones theCONTAINS_TOKENconditions search, whichever condition of ananylist matched it (anEQone included). List inpropertieseverything 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 underCONTAINS_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
propertyorvalue). The publish preview reportsunknown_find_propertywhen 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).
| Field | Required | Notes |
|---|---|---|
entity | yes | Parent entity name. Its hubspot schema object_type (or the friendly-name map) resolves the parent object type. |
source | yes | JSONPath to the parent HubSpot object id (e.g. $.payload.hs_object_id). |
associations | yes | List of association specs (see below). |
target | no | Defaults to hubspot; only hubspot is supported. |
Each entry in associations is a dict:
| Key | Required | Notes |
|---|---|---|
entity or object | yes | Target object/entity to fetch (resolved to a HubSpot plural object type). |
as | yes | Output key under which the hydrated result is stored in the step blob. |
cardinality | no | "one" (single object, or null if none) or "many" (list; default). |
properties | no | Properties to hydrate on each associated object. |
association_labels | no | List of association labels. Keeps only the records whose association carries one of them. Default: absent, no filter. See Filtering by association label. |
association_type_ids | no | List 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_types | no | true 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_labelsmatches 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_idsmatches 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, readassociation_labelson 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: onethe result is the first record that passes the filter, in the order HubSpot lists the associations;nullwhen none does. - A filter that matches nothing yields
[](ornull), whichwhen: {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:
| Field | Content |
|---|---|
association_labels | The labels of the association between the parent and this record, e.g. ["Decision maker"]. Empty when the association has no label. |
association_type_ids | The 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.
sourceis a HubSpot object id, NOT an external id. This is the opposite ofassociate’sfrom_external_id/to_external_id, which take the external id and resolve it through the identity map.find_associated.sourcegets no lookup at all: whatever it resolves to is sent to HubSpot verbatim as the parent object id.The trap is that an
upsertstep’s output exposes both ids,.id(HubSpot) and.ext_id(external), so theassociateidiom copied onto afind_associatedin the same flow publishes cleanly and then 404s on every event (Object not found. objectId are usually numeric.):{"source": "$upsert_deal.ext_id"} // wrong: external id, 404s every event {"source": "$upsert_deal.id"} // right: HubSpot object id {"source": "$.payload.hs_object_id"} // right: the id carried by the eventTwo guardrails cover the mistake. Preflight (
GET .../preflight) and the publish response flagfind_associated_source_not_object_idwhensourceis statically provably not an object id: a$<step>.ext_idref, or a non-numeric literal. It is a warning, never a gate, because a numeric string is indistinguishable from a real object id, so the check stays silent on anything it cannot prove ($.payload.*paths included). At runtime, a 404 on the parent’s association read fails the step permanently (no retry), naming the step id, thesourceexpression and the value it resolved to. That parent 404 is always a hard failure. A 404 while hydrating an individual associated id is a different case and IS tolerated: HubSpot’s association index keeps listing an object for a while after it was archived, so such an id is skipped with a warning and simply drops out of the blob. The tolerance never extends to the parent.
Note: hydration is one
get_objectper associated id (n+1 reads). Acceptable for low-cardinality parents; a batch read is a future optimization.
Example (build an enrichment blob from a deal’s associated company + contacts):
{"id": "enrich", "action": "find_associated", "target": "hubspot",
"entity": "deal", "source": "$.payload.hs_object_id",
"associations": [
{"entity": "company", "as": "company", "cardinality": "one", "properties": ["name", "domain"]},
{"entity": "contact", "as": "contacts", "cardinality": "many", "properties": ["email", "firstname"]}
]}upsert, create, update, delete
Four variants of writing a single record. They share required fields and
differ in semantics (upsert = create-or-update, create = insert only,
update = modify only, delete = remove). upsert/create/update write
to HubSpot only; delete also has a REST-destination variant
(below).
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:
| Action | Record found | No record found |
|---|---|---|
upsert | Updates it. | Creates it. |
update | Updates it. | Does not create it. The flow stops as a skip with skip_kind update_record_not_found. |
create | Does 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
notifyincluded, 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’sstep_trace. When an earlier step of the flow, or another flow of the same event, already wrote, the event stayssuccessand 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_traceentry readsskipped, withskip_kindcreate_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
propertiesof that output are the values this event mapped, not a read of the record on HubSpot. - Its inline
associationsare 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
skippedwith thatskip_kindonly 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:
updateon a record the connector had mapped and that has since been deleted on HubSpot does not recreate it, also when the entity declareson_target_deleted: recreate: recreating is a create. With the default policy the event is quarantined, as forupsert. Replaying the event after releasing the quarantine does not recreate the record either: anupdateflow is skipped again. To bring the record back, send the event through a flow that usesupsert.- When the mapped record is gone,
updatefirst 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. createdoes 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. Anupsertorupdatestep on the same entity does detect the deletion and applies the entity’son_target_deletedpolicy, 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 withplugsync connector identity remove <connector> --entity <name> --external-id <id>(orDELETE /api/connectors/{connector_id}/identity-mappings/record): the nextcreateevent 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
createskip, because the connector already knows that record. - A
createthat 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),
createis 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,updateandcreatemake 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
updatestep against an assumed existing record and marks the step withsimulated_existing_record, so the projection stays visible. Acreatestep is rehearsed as a create. - The batch actions (
create_many,update_many,upsert_many) are not affected: they report per item, as described below.
| Field | Required | Notes |
|---|---|---|
target | yes | "hubspot" for all four. upsert/create/update accept nothing else - use post instead for external writes; delete also accepts a REST destination name. |
entity | yes | Entity name. |
data | no | Accepted by the schema; not read by the runtime for these four actions (see caveat below). |
associations | no | upsert/create/update only (NOT delete, see below). List of {to_entity, to_external_id, association_type_id?, association_category?} entries - see “Associations” below. |
fields | no | upsert 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
targetoutside the list is not written. Atarget_templatemapping is never filtered byfields. - The identity property is always written, and the list must name it.
fieldsis accepted onupsert,upsert_manyandpostonly. Onupsertandupsert_manyit requirestarget: "hubspot". Onpostit filters the body projected from the target’s schema, needsentity, 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:
| Condition | Message |
|---|---|
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 config | flow '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 property | flow '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}"}| Field | Required | Notes |
|---|---|---|
target | yes | The destination (event source) name. Unknown name -> delete: unknown target '<x>'. |
entity | yes | Entity whose identity mapping resolves the record to delete. |
url | yes | Path appended to the destination’s outbound.base_url. Must contain the literal {external_id}, replaced with the mapped external id. May also carry {$...} path refs for other dynamic segments. Omitting the field fails validation with delete step requires url when target is not 'hubspot'; omitting the placeholder fails with delete step url must contain '{external_id}' when target is not 'hubspot'. |
method | no | HTTP verb, DELETE by default. POST/PATCH/PUT for targets that model deletion as a state change (logical delete, e.g. an /archive endpoint). |
headers | no | Extra headers merged over the destination’s resolved auth headers (same mechanism as post). |
No request body is ever sent: the record is identified by the url alone. That
is why {external_id} is mandatory rather than conventional - it is the only
mechanism that names THIS record (a {$...} path ref addresses a parent
resource, not the record being deleted, and there are no params), so a url
without it could only ever hit the collection endpoint while still dropping the
identity mapping.
The external id comes from the identity map, never from the payload. The
event carries HubSpot’s object id; the destination knows the record by its own
id, and the identity map is the only place holding that pair. So the step
resolves the HubSpot object id from the event (the entity’s target-schema
identity_source_path, else $.payload.object.id / $.payload.objectId -
same chain the identity-aware post uses), looks the mapping up by
that id, and substitutes the resulting external id into url.
On a HubSpot journal
.deleteevent, setidentity_source_pathexplicitly. The default chain resolves neither of its two candidates on a deletion:objectisnull(the enrichment GET 404s, by definition) and the id lives underevent. Without"identity_source_path": "$.payload.event.objectId"on the target schema, every deletion raises a permanent error: it is not retried, and the failure shows on the event with the error attached to the step. Publishing such a config returns the warningdelete_rest_identity_source_path_unresolvable, which names the path to declare; the runtime error names it too whenever the failing payload actually carries an id there. See reacting to a deletion.
Outcomes:
| Situation | Behaviour |
|---|---|
| No identity mapping | Quiet, observable no-op - 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. |
| 2xx | Deleted. The identity mapping is removed. |
| 404 | Treated as already deleted: a delete is defined by its end state, and the record is gone. The identity mapping is removed, so redeliveries stop 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, 429 | Transient -> retried (429 honours the target’s Retry-After). The mapping is kept, so the retry can still resolve the id. |
| other 4xx | Permanent 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).
| Field | Required | Notes |
|---|---|---|
to_entity | yes | Entity name of the association target. Missing/falsy is a config error: the step raises <action> step '<id>': associations[<i>] missing 'to_entity', not a silent no-op. |
to_external_id | yes | $... ref resolving to the target’s external id. An unresolved ref (e.g. the event carried no counterpart) is a graceful skip - no call, no error, mirrors associate’s missing_endpoint no-op. |
association_type_id | no | Explicit HubSpot association type id (e.g. 279 contact<->company). Omitted -> auto-discovery, same as associate. |
association_category | no | Category 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'.
| Field | Required | Notes |
|---|---|---|
entity | yes | Entity whose HubSpot identity property is renamed. |
target | no | "hubspot", or omitted (same effect). |
from_external_id | yes | $... ref resolving to the OLD external id (the identity-map lookup key). |
to_external_id | yes | $... ref resolving to the NEW external id the record is renamed to. |
guard_when | no | A when condition (see below) evaluated against the CURRENT record’s live HubSpot properties, scope $record.* (read-before-write, 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_missing | no | from_external_id has no identity mapping. skip (default): the flow proceeds - a later upsert on to_external_id does a normal create (“first order recycles the cart, later ones create new deals”). fail: hard error. |
on_promoted | no | to_external_id is already mapped (redelivery, or already promoted independently). skip (default, idempotent) or fail. |
on_superseded | no | from_external_id is itself a superseded alias of ANOTHER key: the source was already consumed by an earlier promotion. fail (default): hard error, promotion chains are not supported (depth 1). skip: the promotion is declined with an audit row and the flow PROCEEDS, so the downstream upsert creates to_external_id as its own record. Use skip when the source is 1-to-N (one cart producing several orders): the second order is a new record, not a rename of the first. |
on_failure | no | The promotion write fails permanently. fail (default): standard error classification (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:
resolution | Written when | Governed by |
|---|---|---|
skipped_stale_guard | guard_when evaluated false against the record’s live properties: the promotion is stale and zero writes happened. | Config: guard_when |
superseded_source | on_superseded: skip declined the promotion because from_external_id is already a superseded alias of ANOTHER key. The flow proceeds and the downstream upsert creates to_external_id as its own record. | Config: on_superseded |
recreated | on_failure: recreate archived the old record and created a new one under to_external_id. Declared data loss, so the audit row is mandatory. | Config: on_failure |
identity_superseded | A late upsert/update/delete resolved an identity mapping that is itself a superseded alias, and the write was skipped instead of clobbering the promoted record. Not produced by a promote_identity step at all: see “Late events on a superseded key” below. | Platform, not configurable |
The two superseded values are the pair most easily confused, and they answer
different questions. superseded_source is about this promotion’s SOURCE key
being already spent, and you get it only because you asked for it with
on_superseded: skip. identity_superseded is about a write ARRIVING later on
a spent key, and it is platform behavior on every HubSpot write path (the
anti-clobber for late events): no config field turns it on or off.
skipped_missing and skipped_already_promoted write no conflict row.
Both are ordinary idempotency outcomes (nothing to rename, or the destination
key is already mapped) rather than declined work, and they are observable only
in the step output.
$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).
| Field | Required | Description |
|---|---|---|
target | yes | Must be hubspot. |
entity | yes | Entity whose hubspot field_mappings become the submitted form fields. |
form_id | yes | HubSpot form GUID: $... ref or literal. Unresolved at runtime is a hard error. |
context | no | Attribution $... refs, e.g. {"hutk": ..., "pageUri": ..., "pageName": ...}. Values that resolve to nothing are omitted (hutk is optional in the Forms API); an all-empty context is not sent. |
Missing field error: submit_form step requires target 'hubspot' /
submit_form step requires entity / submit_form step requires form_id.
The step output is {"operation": "submit_form", "form_id", "fields", "context", "response"}.
Example (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.
| Field | Required | Notes |
|---|---|---|
target | yes | The 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). |
entity | no | Entity 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. |
url | yes | Absolute or schema-base-relative URL. May carry {$...} path refs for dynamic segments. |
body | no | JSONPath or inline dict. Omit (with entity set) to project entities.<entity>.schemas.<target>.field_mappings instead — see “Body projection” below. |
headers | no | dict of headers, sent as written and merged over the target’s authentication headers (a header named in both takes the step’s value). |
fields | no | Allowlist 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_method | no | PUT (default) or PATCH: the verb used when the record already exists. Identity-aware writes only - see below. |
update_url | no | Update path template, default url + / + the external id. Supports {external_id} and {$...} path refs. Identity-aware writes only. |
Missing field error: post step requires target and url.
The field is
update_method, notmethod.methodbelongs todeleteand is rejected on apostwith a 422 that points here. Apostis not POST-only: see the identity-aware round trip below.
Example (HubSpot -> 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 answers | body in the step output | Step trace |
|---|---|---|
2xx with a JSON body | the parsed JSON | success |
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:
| Aspect | Behaviour |
|---|---|
| Node shape | each (a $-ref), entity, schema all required; fields optional. |
| Element scope | Each 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 rule | The 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 list | One 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 else | The step fails permanently, with the ref named in the error: a target must never silently receive a scalar where it expects a collection. |
| Placement | Any 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 aneachnode 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. eachoutside the body: a node inheaders,paramsordatais rejected, because those resolve$-refs and nothing else.url(a string) andwhen(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:
| Rule | Why |
|---|---|
Values are url-encoded (/, ?, spaces included) | A resolved value is data, never structure. Unencoded, an id containing / silently re-targets the call at a different resource. |
| An unresolved, empty or non-scalar ref fails the step permanently before the call | Substituting 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 refused | URLs 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 untouched | It is not a $-ref. It keeps its own meaning (the identity map’s id for this step’s own entity) and its own substitution site, so manifests published earlier behave identically. |
A property read only by a path still counts: the trigger-property derivation
and the outbound-freshness touched set both extract refs out of {...}
placeholders, so /companies/{$.payload.object.properties.mp_id}/users routes
its own event.
notify
Send a notification (Slack, email, webhook, etc. depending on target type).
| Field | Required | Notes |
|---|---|---|
target | yes | Notification target: a destination resolved like a post target (base URL and authentication headers). |
url | no | Required 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. |
body | no | JSON 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. |
headers | no | dict 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’sRetry-Afteris 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. A5xxuses a short fixed backoff instead (one second, then two):Retry-Afteris read on429only. - 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
2xxcounts as delivered, including one whose body is not JSON - a webhook answering200 text/plainhas 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, notsuccess: 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}/statusanswerspartial_failure, notcompleted. 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.
| Field | Required | Notes |
|---|---|---|
plugin | yes | JSON 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: falseplugin steps,transform,find,skipand the other read-only actions) closesskipped, notsuccess, and is not counted. The step itself still readssuccessin the sync event’sstep_trace, with"writes": falseon its entry so the reason for theskippedoutcome is visible. - As soon as another step of the same flow writes (an
upsert, an association, a plugin step withoutwrites: false), the event is counted as usual. - A failed outcome
declared by the plugin still closes the event
failed:writes: falsechanges 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.
writesmust betrueorfalse; any other value is rejected when the draft is saved withplugin.writes must be true or false. Leaving it out is the same astrue.
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.
| Field | Required | Notes |
|---|---|---|
target | yes | Must equal "hubspot". Bulk actions are HubSpot-only. |
entity | yes | Entity name. |
items | yes | JSONPath expression resolving to a list of records to process. |
chunk_size | no | Batch size (1-100). Defaults to HubSpot’s API limit. |
output | no | A 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. |
reconcile | no | upsert_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_ref | no | delete_many only. Per-item $... ref read directly as each item’s external id, bypassing field-mapping projection (see below). |
Missing field errors:
<action> step requires items<action> step requires entity<action> step requires target 'hubspot'
Example:
{"id": "bulk_upsert", "action": "upsert_many", "target": "hubspot",
"entity": "contact", "items": "$.canonical.contacts", "chunk_size": 50,
"output": "batch_out", "reconcile": true}delete_many with external_id_ref: wiping discovered records
delete_many normally re-projects each item through the entity’s
field_mappings to derive the external id to archive - which reconstructs the
CURRENT event’s key. That cannot target records discovered by
find_associated, whose hydrated HubSpot objects
({id, properties}) carry no source-event fields and whose real key the
current event can no longer reproduce (e.g. wiping a promoted deal’s stale
cart line items after promote_identity).
external_id_ref bypasses projection: it is a per-item ref (typically
$._item.*) resolved against each item to read its external id straight off.
The item is then archived through the normal identity-map path (archive +
mapping delete, and the same superseded-key skip-with-audit as every other
delete). An item whose ref resolves to nothing is a mapping_failed entry, not
a crash. Hydrate the identity property on the find_associated side so the ref
has something to read:
{"id": "find_stale", "action": "find_associated", "target": "hubspot",
"entity": "deal", "source": "$upsert_deal.id",
"associations": [
{"entity": "line_item", "as": "stale",
"properties": ["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.
| Field | Required | Notes |
|---|---|---|
items | yes | JSONPath resolving to a list of association payloads. |
chunk_size | no | Pairs 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_id | no | Step-level default type id for the pairs that carry no type_id of their own. |
association_category | no | Category 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.
| Field | Required | Description |
|---|---|---|
target | yes | Must be hubspot. |
entity | yes | Entity whose hubspot schema gives the object type. |
filters | no | AND 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. |
properties | no | HubSpot property names to read. |
window | no | {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_records | no | 1 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.
| Field | Required | Description |
|---|---|---|
rows | yes | An 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). |
key | yes | Relative 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. |
columns | with a $-ref rows | Field names or {field, header}; a dotted field reads a nested value. With an each node, defaults to the schema’s mapping targets. |
format | no | csv (the only format). |
delimiter | no | One character, default ,; not a quote or a line break. |
header | no | Write the header row (default true). |
escape_formulas | no | Prefix ' 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):
| Variant | Shape |
|---|---|
WhenExists | {"exists": "<jsonpath>"} |
WhenFound | {"found": "<step-id>"} |
WhenEquals | {"equals": {"<jsonpath>": <value>}} |
WhenGt | {"gt": {"<jsonpath>": <value>}} |
WhenLt | {"lt": {"<jsonpath>": <value>}} |
WhenIn | {"in": {"<jsonpath>": [<values...>]}} |
WhenNot | {"not": <inner WhenCondition>} (recursive) |
WhenAll | {"all": [<cond>, ...]} (AND compound; recursive) |
On a REGULAR step when (this section), <value>/<values...> in
equals/gt/lt/in is always a LITERAL - it is compared as-is, never
resolved as a $... reference, even if it happens to be a string starting
with $ (e.g. a currency-prefixed amount). This is deliberate and
load-bearing for every already-published connector: the one documented
exception is promote_identity’s own guard_when (see
above), which opts into resolving a $-prefixed value
too - scoped to that single call site, not a change to when in general.
{"all": [<cond>, ...]}- true iff every sub-condition is true (AND). An empty list is vacuously true. Use to combine conditions that no single predicate can express, e.g. a value inclusion with a not-empty guard:{"all": [{"in": {"$.x": ["a", "b"]}}, {"not": {"in": {"$.y": ["", null]}}}]}.
WhenFound’s <step-id> is a reference to an earlier find/find_associated
step, with or without the leading $ ("find_company", "$find_company" and
"$.find_company" all resolve identically). It is true only when that step’s
stored output is non-null and non-empty; false both when the step ran and
found nothing (a find with no match, or a find_associated with an empty
association list, stores null/[]) and when the step was itself skipped by
its own when (a skipped step never stores an output at all).
When a when condition evaluates to false the runtime skips the step and
continues with the next one.