Skip to Content
ReferenceConfig V3Entities

entities[] reference

An entity is a logical record type (company, contact, deal, …) plus one or more schemas that describe how that record looks in each system the connector touches. The HubSpot schema (schemas.hubspot) is by far the most common.

See connector.md for the surrounding ConnectorConfigV3 envelope, and simple-vs-federated.md for when to use a single hubspot schema versus a multi-schema federated layout.

Shape at a glance

A minimal simple-mode entity (1 source, HubSpot target):

{ "name": "company", "schemas": { "hubspot": { "object_type": "companies", "identity": {"strategy": "match_or_create", "property": "hs_tax_id"}, "identity_source_path": "$.payload.id", "identity_response_path": "$.id", "field_mappings": [ {"source": "$.payload.name", "target": "name"}, {"source": "$.payload.vat_number", "target": "hs_tax_id"} ] } } }

A federated entity adds canonical and source-specific schemas alongside hubspot; see simple-vs-federated.md.

EntityV3 fields

FieldTypeRequiredNotes
namestringyesUnique within the config. Used by flow steps as entity.
schemasdict[str, EntitySchemaSpec]yesKeyed by system name (hubspot, canonical, <source>).
payload_rootstring or nullnoRe-roots $.payload.* for this entity’s own field_mappings/transform-step sources. See “Multi-entity envelopes: payload_root” below.
scope_overrideslist of stringsnoDefaults to []. Labels (typically ids from settings.scopes) kept with the entity for your own bookkeeping. Stored as written and not cross-checked against settings.scopes; no flow reads it, so it changes nothing at runtime.

The schemas dict accepts arbitrary keys, but two are behaviorally special:

  • hubspot - recognized by HubSpot-targeting actions like find / upsert. upsert/create/update accept no other target, and a find pointed at another target needs its own url (see step-actions.md).
  • canonical - the conventional intermediate shape that transform steps write into (see simple-vs-federated.md).

Other keys are user-defined labels for a non-HubSpot system — a source schema in a federated entity, or a schema a post step targets directly. A post step with entity set and no explicit body projects that schema’s field_mappings into the outbound request body; see “Which action reads a schema’s field_mappings” below for the full consumer list.

Like a schema, its identity object and each field mapping, an entity accepts unknown extra keys: they are kept as written and never read by the runtime. So that a typo does not go unnoticed, preflight reports them in an unknown_entity_keys warning that lists them.

EntitySchemaSpec fields

FieldTypeRequiredNotes
object_typestring or nullnoThe target system’s object identifier (e.g., "companies", "0-2"). See “object_type normalization” below.
identityIdentityConfig or nullnoHow to identify existing records. See “Identity” below.
identity_source_pathstring (JSONPath)noJSONPath into the inbound payload that yields the external id. Defaults to a fallback chain when omitted — see “identity_source_path default” below.
identity_response_pathstring (JSONPath)noJSONPath into the RAW create response body (not a step reference — see “identity_response_path resolution root” below).
field_mappingslist[FieldMapping]noDefaults to []. See “Field mappings” below.
on_target_deleted"skip" or "recreate"noOn the hubspot schema: what to do when the HubSpot record an external id is linked to no longer exists. Defaults to "skip". See “Target record deleted” below.
identity_guardIdentityGuard or nullnoOn the hubspot schema: hold back an update that changes too many identity fields of an existing record at once. See “Identity guard (identity_guard)” below.

object_type normalization

The six standard HubSpot object types are stored under their plural name: contact, company, deal, ticket, product and line_item (and any case or surrounding-whitespace variant of these or of their plurals, such as "Contacts" or " deal ") are saved as contacts, companies, deals, tickets, products and line_items. The draft you read back carries the normalized value. Any other value, such as a custom object’s fully qualified name or an objectTypeId (0-2, 2-12345), is kept exactly as written. The same rule applies to a pipeline’s object_type (see connector.md).

A schema accepts unknown extra keys: they do not raise, but the runtime ignores them. That includes a per-schema credential_name left over from older configs: the HubSpot side always uses the connector’s portal, and an outbound call authenticates with the credential its event source names in config.outbound.credential_name (see EventSources).

Identity

{"identity": {"strategy": "match_or_create", "property": "hs_tax_id"}}

Only identity.property drives the match. An upsert first looks the record’s external id up in the connector’s identity map, then, on a miss, searches HubSpot by property, and creates the record only when neither finds it (upsert_many runs that search only with reconcile: true, see step-actions.md). An upsert against a hubspot schema without identity.property fails.

strategy is required but free-form: it is stored as written and does not change how a flow resolves a record. Use it as a label for the intent (match_or_create is the conventional value). The identity object accepts extra keys, so property and the optional fallback_search (below) ride along on the same object.

An identity.property on a hubspot schema does not need to pre-exist on the portal: a custom (non-hs_*) identity property is created automatically at publish. See the Schema provisioning reference.

A record that already exists in HubSpot but was never synced by this connector has no local mapping and no value in identity.property, so a plain upsert would create a duplicate. On a hubspot schema, identity.fallback_search declares an ordered list of other searches to try before creating:

{ "identity": { "strategy": "match_or_create", "property": "customer_code", "fallback_search": [ {"property": "email", "operator": "EQ", "source": "$.payload.email", "lowercase": true}, {"property": "hs_additional_emails", "operator": "CONTAINS_TOKEN", "source": "$.payload.email", "lowercase": true} ] } }
FieldTypeRequiredDescription
propertystringyesHubSpot property searched at this level. It must exist on the portal (or be written by one of the schema’s field mappings, so publish creates it): a level on an unknown property is reported as an error by the portal checks.
operatorEQ or CONTAINS_TOKENno (default EQ)EQ matches the whole value. CONTAINS_TOKEN matches one entry of a multi-value property, such as a semicolon-separated list of secondary email addresses.
sourcestring ($-path)yesWhere the searched value is read on the record, resolved exactly like a field mapping source (payload_root applies).
lowercasebooleanno (default false)Lowercase the value before searching. The value is always trimmed.

The chain is checked when you save the draft, and a config that breaks one of these rules is rejected with the message shown:

RuleMessage
A level accepts only the four keys above.The unknown key is reported as an extra field that is not permitted.
property is not blank (it is stored trimmed).fallback_search.property must be a non-blank property name
source starts with $.fallback_search.source must be a $-path (e.g. $.payload.email), got '...'
The list is not empty: to disable the chain, omit the key.identity.fallback_search must declare at least one level (omit the key to disable the chain)
At most five levels.identity.fallback_search declares N levels, at most 5 are allowed
No level searches identity.property itself, which is always searched first.identity.fallback_search level on 'x' repeats identity.property, which is already searched first
No two levels are identical (same property, operator, source and lowercase).identity.fallback_search has a duplicate level on 'x'
The chain is declared only on the hubspot schema, the one an upsert searches.entity 'name': identity.fallback_search is only supported on the hubspot schema, not on 'schema'

The chain runs only after the local mapping and the identity.property search both miss, for single events and batches alike: in a batch, the records that carry a chain are always searched on identity.property first. Text properties are compared case-insensitively, so a batch finds the same records as a single event. Dropdown (enumeration) properties are compared exactly, as HubSpot compares them: SKU-A and sku-a are two different options. When the portal’s property definitions cannot be read, every property is compared as text. Levels run in order, at most five, and the object type can be any HubSpot object, standard or custom. For each level:

  • No match: the next level is tried. When every level misses, the record is created as before. A level whose value is missing or blank on the record is skipped.
  • One match: that record is adopted. The event updates it, writes the identity property on it and saves the mapping, so the next event finds it directly. The write is reported as an update, not a create.
  • More than one match: the record is ambiguous. Nothing is adopted and nothing is created: the event fails with a message naming the level and the candidate record ids (sorted), so you can merge or fix the duplicates in HubSpot and replay it. In a batch, only the ambiguous record fails; the others are written.

A candidate whose identity property already holds a different value belongs to another source record and is never adopted, and within one batch a record is adopted at most once. Without fallback_search nothing changes: the upsert creates when the mapping and the identity property miss. A dry-run preview does not search the portal, so it always shows the chain falling through to a create.

Independently of the chain, when HubSpot rejects a create because the record already exists and names its id (a contact with the same email, for example), the upsert adopts that record instead of failing: it updates it and saves the mapping, as on a fallback match. The same ownership rule applies on every path, single events and batches: when that record’s identity property already holds a different value (compared case-insensitively), or HubSpot refuses to let it be read, the create’s rejection is reported unchanged and nothing is adopted; a temporary HubSpot error on that read is retried like any other. In a batch only that record fails, and a record is adopted at most once. On a single event the adopted record is written like any record found by a search, so the identity guard and conflict resolution apply to it.

Publish-time identity validator

When an entity schema declares an identity config and the schema key is not hubspot, publish requires identity_response_path to be set. Without it the runtime cannot writeback the mapping after a successful create, so the next event for the same record would duplicate. Missing the path blocks publish with a 422, not a draft-save error.

identity_source_path default

When a schema declares identity but does not set an explicit identity_source_path, the runtime tries two candidates in order, using the first one that resolves to a non-empty value:

  1. $.payload.object.id — the shape every HubSpot-triggered outbound event actually carries today. Journal v4 enrichment wraps the raw event as {"event": {...raw event...}, "object": {"id": ..., "properties": {...}} | None}; webhook v2 enrichment converges to the same shape. This is the id every journal-sourced outbound step actually reads.
  2. $.payload.objectId — kept only for retrocompatibility with a flat, pre-enrichment webhook-shaped payload. No live outbound call site produces that flat shape today, but a hard failure from silently dropping support for it would be worse than one extra JSONPath attempt.

Neither candidate is a fit for event_api (inbound enterprise) payloads: those carry their id as a sibling $.external_id, never nested under $.payload (the message-level external id, see “JSONPath conventions” below). An identity-aware schema fed by event_api still needs an explicit identity_source_path: "$.external_id" — the default fallback chain is specific to HubSpot-triggered outbound events.

If neither candidate resolves to a non-empty value, the step fails permanently with an error naming both paths that were tried. Set an explicit identity_source_path only when the entity’s payload genuinely differs from both defaults (e.g. event_api, above, or a federated entity whose non-HubSpot source schema carries its own id shape) — an explicit override always wins and skips the fallback chain entirely, with no retry if it fails to resolve.

Degraded enrichment (enrichment_degraded marker)

The fallback chain above does not change to cover a degraded HubSpot enrichment: when object is null because the enrichment itself failed rather than because the object genuinely doesn’t exist, the only surviving id lives at $.payload.event.objectId — not one of the two default candidates, and it deliberately never becomes a third one. Adding it as a candidate would let the step proceed with a null/empty object body, risking a null write to the target or a silent no-op sync. A truthful failure with a clear recovery path is preferred.

Instead, the event envelope is marked explicitly: an enriched journal event carries a sibling enrichment_degraded: true key (alongside event/object) when:

  • the object GET fails transiently (5xx, timeout), including when the property-isolating retry that follows a proven 400 also fails;
  • the HubSpot enrichment client itself could not be built for the whole poll tick (credential expired/revoked) — no GET was even attempted.

The marker is not set on the object_deleted path (the object genuinely doesn’t exist — see the section below) nor when enrichment is skipped by choice (enrich=False). A DELETE journal event’s expected 404 also does not get the marker: that is the correct shape of a delete, not a degradation.

When the default chain fails to resolve AND enrichment_degraded is set, the step fails permanently with an error that names the degraded enrichment explicitly and suggests a journal replay instead of retrying the message: retrying does not help because the queued message is frozen with the same missing object body; only replaying from the journal re-enriches the event at the source.

identity_response_path resolution root

identity_response_path and identity_source_path are NOT resolved the same way, and neither is resolved the same way as the $<step_id>.* step-output references described in “JSONPath conventions” below. Confusing the two is an easy, high-damage mistake: mix them up and the identity mapping is silently never written, so every following event for the same record POSTs a new one instead of PUTing the existing one (record duplication).

  • identity_source_path (and every field_mappings[].source) goes through the rich field-mapping resolver, which understands the $.payload.* / $.canonical.* / $<step_id>.* roots.
  • identity_response_path goes through a plain JSONPath evaluation with no roots at all: it walks directly into the raw JSON body of the create response. $. IS the response body — there is no payload, canonical, or body key to step through first. A target that returns {"resource": {"id": "abc"}} on create needs "identity_response_path": "$.resource.id", never "$.body.resource.id".

The $.body.* form is real, but it belongs to a different feature: a step-output reference like $my_step.body.resource.id (or $.my_step.body.resource.id) reads ctx.outputs["my_step"]["body"], because ctx.outputs[step_id] for an HTTP-ish step DOES wrap the parsed response under a "body" key. identity_response_path has no step id to anchor a step-output reference on — it fires against the response of the step that is currently executing, before that step has written anything to ctx.outputs. Writing identity_response_path: "$.body.*" applies the step-output convention where it does not apply.

Because this misuse is common enough to have caused a production incident, the $.body. prefix specifically is caught in two places, before it ever runs against real data:

  • Publish rejects any identity_response_path starting with $.body. with a 422 explaining the correct form.
  • Preflight (GET .../preflight) flags the same prefix as an error-severity finding while still in draft, before the operator even attempts to publish.

Dry-run stub shape. The dry-run rehearsal (POST .../draft/flows/{name}/dry-run) never calls the real target: its stub HTTP client answers every outbound create with a flat {"id": "dry-run-N"} body. An identity_response_path that is correct for the live API but reads a non-flat shape (e.g. "$.resource.id") fails ONLY in dry-run, against that flat stub — the source’s config.outbound.dry_run_response field lets an operator supply an example response body (e.g. {"resource": {"id": "ext-123"}}) shaping the stub after the real API instead, so the same path resolves in the rehearsal too. Absent, the flat default applies (today’s behavior, unchanged). Either way, the resulting trace entry carries stubbed_external_response: true with an explanatory note: the value shown there — resolved or not — never came from the real target.

And more generally, at runtime: even a well-formed path that simply does not resolve against a successful (2xx) create response fails the step permanently — never a silent warning. A step never reports success with the mapping unsaved: the failure surfaces before the step writes its output, so writeback_ok: false never appears in a step trace.

Target record deleted (on_target_deleted)

Once an external id has synced, it is linked to one HubSpot record and every later event for that id updates that record. on_target_deleted decides what happens when that record is gone: deleted, archived, or removed for a privacy request on the HubSpot side.

{ "name": "contact", "schemas": { "hubspot": { "object_type": "contacts", "identity": {"strategy": "match_or_create", "property": "erp_customer_id"}, "on_target_deleted": "skip", "field_mappings": [ {"source": "$.payload.id", "target": "erp_customer_id"}, {"source": "$.payload.email", "target": "email"} ] } } }

The deletion is noticed on the first event that tries to write the record after it is gone. Before deciding anything, the platform searches HubSpot for the external id on the entity’s identity.property. If a record carries it (the original was merged into another one, or someone created it again by hand), the link moves to that record, the event is written there and nothing else happens. The setting only matters when the search finds nothing.

ValueFirst event after the deletionLater events for the same external id
"skip" (default)Not written. The event ends quarantined, with error_struct.error_code target_deleted_quarantined. The external id is marked as deleted on the target.Not written. Each one ends skipped with skip_kind target_deleted. They are not quarantined again.
"recreate"A new HubSpot record is created with the properties this event projects, and the external id is linked to it. The event completes normally.Update the new record, like any other.

The statuses in the table are those of an event whose flow writes the record with an upsert step. In a batch step (upsert_many) the same setting is applied record by record, and a deleted record does not stop the other records of the same event.

skip is the default because a deletion in HubSpot is usually a decision somebody took, and silently undoing it on the next event would be worse than stopping. Existing configs that do not carry the key behave as skip.

With recreate, what comes back is a new record, not the old one. It holds only the properties projected by the event that recreated it: the engagements, activities, associations and createdate of the deleted record are not carried over.

Each of these outcomes leaves a row in the connector’s Conflicts tab, with strategy target_deleted: the detection, every later skipped event, and the recreation (which names both the deleted record id and the new one).

Bringing a record back in sync under skip

The quarantined event is the handle. Its error_struct names the entity, the external id and the HubSpot id that no longer exists (entity_type, external_id, deleted_hubspot_id, detected_at). The skipped events that followed cannot be replayed, so it is the quarantined one you release, with a forced replay:

curl -X POST \ "https://app.plugsync.com/api/connectors/{connector_id}/sync-events/{event_id}/replay?force=true"

What the forced replay does depends on what is in HubSpot at that moment:

  • The record is still gone. It is created again from the replayed event’s payload and the external id is linked to the new record, with the same loss as recreate above.
  • You restored the record from the HubSpot recycle bin first. The replay writes to the restored record and clears the deleted mark. Nothing is recreated and the record keeps its history.

Either way, events for that external id are written again from then on. The replay carries the payload of the quarantined event, not of the skipped ones that came after it: if the record changed in the meantime, send a fresh event from the source once the replay has completed.

To stop handling these by hand, set on_target_deleted: "recreate" on the entity and publish. Records already marked as deleted stay marked: release each of them with the forced replay above.

Finding the quarantined rows, and the rules of a forced replay, are in Replaying a failed record.

Identity guard (identity_guard)

An upsert normally trusts the identity match: when the external id or identity.property finds a record, the event updates it. A join error on the source side can break that trust, reassigning the name, email and tax code of one customer to another under the same customer code. Each such event is an ordinary update, and HubSpot accepts it. identity_guard on the hubspot schema turns a suspicious update into a quarantine instead:

{ "schemas": { "hubspot": { "object_type": "contacts", "identity": {"strategy": "match_or_create", "property": "customer_code"}, "identity_guard": {"fields": ["firstname", "lastname", "email"], "threshold": 2}, "field_mappings": ["..."] } } }
FieldTypeRequiredNotes
fieldslist of stringsyesHubSpot property names (the target of a mapping, not a $ source path) that describe who the record is. Must be a non-empty list of non-blank names. Do not list identity.property: it is the match key and is never compared, even when listed.
thresholdintegerno (default 2)How many of fields must change together for the event to be held back. At least 1 and at most the number of names in fields.

The block accepts only these two keys. When you save the draft it is rejected with:

  • identity_guard.fields must be a non-empty list of non-blank property names for an empty list or a blank name;
  • identity_guard.threshold (N) cannot exceed the number of declared fields (M): an event can change at most M of them, so the guard could never reach its own threshold for a threshold the guard could never reach.

A draft saved before that rule existed still reports the same condition as an identity_guard_threshold_exceeds_fields warning in preflight.

How the guard runs:

  • It applies only to an upsert or upsert_many that resolves to an existing record. A create is never guarded: a new record has no identity to reassign.
  • With the block declared, the upsert reads the record’s current values before writing. Without it, the guard adds no read and changes nothing.
  • Only the declared fields the event actually writes are compared, as text (an empty value and an absent one compare equal).
  • When at least threshold of them differ, nothing is written. On an upsert the whole event ends quarantined with error code identity_guard_quarantined; its error detail lists the entity, the external id, the HubSpot record id, the threshold and, for each changed field, the old and the new value. On an upsert_many only that record is held back with the same code, and the rest of the batch is written.

Only the hubspot schema’s block is read: the same block on another schema has no effect. A quarantined event is never released automatically: once you have checked the change is legitimate, replay it with force=true (see Triggering the replay). The forced replay bypasses the guard for that event only.

Field mappings

{"source": "$.payload.vat_number", "target": "hs_tax_id"}
{"source": "$.payload.created_at", "target": "created_at", "transform": "iso_to_unix_ms"}

FieldMapping fields:

FieldTypeRequiredNotes
sourcestring (JSONPath)yesRead path. Common roots: $.payload.*, $.canonical.*, $.event_type.
targetstring or nullone of target / target_templateMeaning depends on which schema this mapping lives on. See “Which action reads a schema’s field_mappings” below.
target_templatestring or nullone of target / target_templateComputed HubSpot property name, used instead of target: each {$.path} token is replaced with the value it resolves to on the event (e.g. "data_offerta_{$.canonical.offer_idx}"). See “Computed targets: target_template” below.
provision_nameslist of strings or nullnoOnly with target_template: every property name the template can expand to, so that publish creates them on the portal. A non-empty list of non-blank names. See “Computed targets: target_template” below.
transformstring, dict, list of dicts, or nullnoName of a registered transform, or a transform pipeline: one step ({"name": ..., "args": ...}) or an ordered list of steps. A single dict is equivalent to a one-element list. See “Transforms” below.
argsdict or nullnoArguments for the flat/bare-string transform. For the dict/pipeline form, apply_transform reads args per-step ({"name": ..., "args": ...}), not this sibling — a single-dict transform with no args of its own merges this sibling in at draft-save time; a dict that already has its own args plus this sibling is rejected as ambiguous.
resolvestring or nullnoOwner resolution marker. See “Owner resolution” below.
on_miss_targetstring or nullnoProperty that receives the value resolve could not resolve, and "" as soon as it resolves again. Only valid together with resolve (a mapping that declares it without one, or with resolve_identity, or pointing at its own target, is rejected when you save the draft). See “Owner resolution” below.
resolve_identityobject or nullnoCross-entity identity reference: the mapping’s value is another entity’s EXTERNAL id, and target receives that entity’s HubSpot object id. See “Cross-entity identity resolution” below. Mutually exclusive with resolve.
skip_if_source_emptyboolean, default falsenoSkip this WHOLE mapping (never write target) when its OWN source resolves to None or "", checked BEFORE transform runs. See “Empty vs. absent sources” below.
defaultany JSON value, default nullnoFallback value written to target when the mapping’s POST-transform value is null or "". null means “no default” (it is indistinguishable from an absent key), never “write null”. Mutually exclusive with skip_if_source_empty — declaring both is rejected when you save the draft. See “Empty vs. absent sources” below.
hubspot_optionslist of {value, label} or nullnoOptions of a dropdown property created at publish (hubspot_type: enumeration). value is required, non-blank and unique across the list; label is optional. See below.
hubspot_typestringnoHubSpot property type used when publish creates the property (default string).
hubspot_field_typestringnoHubSpot field type used when publish creates the property (default text, or select for an enumeration).
hubspot_labelstringnoLabel of the property created at publish.
transform_argsdictnoOlder spelling of args for a bare-string transform, still read.

hubspot_type, hubspot_field_type and hubspot_label are read only when publish creates the property; see the Schema provisioning reference.

A mapping must declare exactly one of target and target_template; a mapping with both, or with neither, is rejected when you save the draft with FieldMapping requires exactly one of `target` or `target_template` (got both or neither). provision_names next to a static target is rejected too (provision_names is only meaningful with `target_template`; a static `target` is provisioned by its own name).

Any other key is kept as written and never read. So that a typo (such as options for hubspot_options) does not go unnoticed, preflight reports every such key in an unknown_field_mapping_keys warning that names the entity, the schema and the mapping.

On a hubspot schema, every mapping’s target (and a target_template’s declared provision_names) is provisioned as a custom property on the portal at publish — you do not create properties by hand, and per-mapping hubspot_type / hubspot_field_type / hubspot_label extras control what gets created. For hubspot_type: enumeration, hubspot_options declares the dropdown’s values — required to create the property at all, since HubSpot rejects an enumeration with no options:

{ "source": "$.payload.status", "target": "deal_status", "hubspot_type": "enumeration", "hubspot_options": [ {"value": "open", "label": "Open"}, {"value": "won", "label": "Won"}, {"value": "lost", "label": "Lost"} ] }

hubspot_options is validated when you save the draft: value is required and must be unique across the list, label is optional. A duplicated or missing value is rejected immediately, before publish. An omitted label is stored omitted — saving and publishing give the list back exactly as you wrote it, so a config kept in version control does not drift — and the property is created on the portal with value as its label:

{ "source": "$.payload.status", "target": "deal_status", "hubspot_type": "enumeration", "hubspot_options": [{"value": "open"}, {"value": "won"}] }

Mechanisms and limits (reserved hs_* / a<appId>_* prefixes, custom object schemas, forms) are documented in the Schema provisioning reference.

Computed targets: target_template

A target_template builds the property name from the event itself, for properties that come in numbered or keyed families:

{"source": "$.payload.datadoc", "target_template": "data_offerta_{$.canonical.offer_idx}", "provision_names": ["data_offerta_1", "data_offerta_2", "data_offerta_3"]}

With offer_idx = 3 the value is written to data_offerta_3. A token reads $.payload.* or $.canonical.* only: its path is made of letters, digits, _ and . after one of those two roots. The template is checked when you save the draft:

  • a template with no {...} token is rejected (target_template must contain at least one {$.payload.* / $.canonical.*} token; use `target` for a static property name);
  • a token with another root, or with anything that is not a plain path, is rejected (target_template token {...} must match $.{payload|canonical}.<path> (no eval, no other roots)).

When any token resolves to null or "" (absent key included), the mapping is skipped for that event: the name would otherwise be truncated (data_offerta_), and HubSpot rejects a write that names a property the portal does not have, which would lose every other field of the record along with it. The rest of the upsert goes through, and the step trace carries a non-fatal warning with code computed_target_unresolved:

{"code": "computed_target_unresolved", "entity": "deal", "template": "data_offerta_{$.canonical.offer_idx}", "token": "$.canonical.offer_idx"}

token is the first token that came out empty, so it names the path to fix (or the upstream step that should have filled it). A template made of a single token and nothing else ("{$.canonical.name}") that resolves empty is skipped without a warning: there is no literal part to truncate. A default on the mapping fills the VALUE written to the property, never the name, so it does not rescue an empty token. If the token reads the payload of an entity that declares payload_root, check that it does not repeat the root (see “Multi-entity envelopes: payload_root” below).

Which action reads a schema’s field_mappings

field_mappings are declared per-schema, but which runtime consumer (if any) applies depends entirely on which schema key they sit under:

  • schemas.hubspot.field_mappings — projected into a HubSpot properties dict, the single projection shared by upsert/create/update/delete/ submit_form and the *_many array actions. target here is a literal HubSpot internal property name (or target_template for a computed one). This is the only schema key the hubspot-write actions ever read.
  • Any other schema, when it is a transform step’s from_schema — the step reads that schema’s field_mappings and writes each mapping whose target starts with "$.canonical.<key>" into the canonical output. A mapping on that same schema whose target is anything else (a bare property name, say) is silently dropped — it is never written anywhere by the transform step (a body-less post step can still consume it directly, see below).
  • Any other schema, when it is a body-less post step’s target — a post step that declares entity and no explicit body projects entity["schemas"][step.target].field_mappings into the outbound request body, with the same projection rule the hubspot path uses (it projects whatever field_mappings list it is handed; it never assumed “hubspot”). target here is a literal key name in the outbound JSON body (no "$.canonical." prefix filtering: EVERY mapping on the schema is applied, transform included). This is FALLBACK-ONLY: a post step that declares its own body never reads the schema’s field_mappings at all, with zero merge between the two — the explicit body always wins outright. data has no effect on this gate either way: it is a base step field used by upsert/update_by_id, but post neither declares nor reads it — only body presence and entity decide. fields further filters the projection to a subset of targets, same mechanism as upsert/upsert_many’s allowlist, but requires entity (see step-actions.md). Unlike upsert/upsert_many, a post allowlist is not required to include the schema’s identity property: it filters fields verbatim (no force-include), because that schema’s identity block (if any) drives only the create-vs-update HTTP routing, not a written property. notify is excluded from this whole projection fallback even though it sends over the same HTTP path as post — a notify step never projects a body.
  • Any other schema, when an each node in a post body names it — a post step whose explicit body contains {"each": "$<ref to a list>", "entity": "<entity>", "schema": "<schema>"} projects that schema’s field_mappings once per element of the resolved list, and sends the resulting list of objects as that key of the body. The entity and schema are the ones named INSIDE the node, which need not be the step’s own entity, and this is the one shape that reads a schema through an explicit body. Each element is bound as $._item.* (and $.payload.*), target is a literal key name in the projected object, and fields inside the node restricts the projection to a subset. See step-actions.md.
  • Any other schema, when nothing reads it — if a schema is not hubspot, is not referenced as any transform step’s from_schema, is not a body-less post step’s target and is not named by an each node, its field_mappings have zero consumers. Declaring them has no runtime effect at all. Preflight (GET .../preflight) flags both silent-drop shapes (an unconsumed schema, or a transform-consumed schema with a non-canonical dead target) as a non-gating warning at draft-preview time — it does not fire on a schema a body-less post step actually projects.

Preflight also validates the post-projection shape itself: a body-less post step whose target does not match any schema key on its entity is flagged as unknown_post_projection_schema (at runtime the step would fail permanently, exactly like an unknown transform from_schema).

Owner resolution

HubSpot’s owner field (hubspot_owner_id) takes a numeric owner id, but source systems often carry the owner as an EMAIL (and outbound the inverse). Owner resolution is a live HubSpot lookup, so it cannot be a (sync, stateless) registry transform; it is a separate marker on the mapping:

resolve valueDirectionEffect
owner_email_to_idinboundMaps the resolved value (an owner email) to the HubSpot owner id.
owner_id_to_emailoutboundMaps the resolved value (an owner id) to the owner email.
owner_ids_to_emailsoutboundList-aware inverse. Maps a ;-joined (HubSpot multi-select) or already-list value of owner ids to a list of emails.
{"source": "$.payload.owner_email", "target": "hubspot_owner_id", "resolve": "owner_email_to_id"}

resolve applies AFTER source is read and any transform runs. The owner directory is fetched once per message (a single GET /crm/v3/owners call, lazy: flows with no owner-resolve mapping never make the call) and indexed in memory in both directions; matching on the email is case-insensitive.

On a miss (the email/id is not in the directory) the target property is set to the empty string "" and the unresolved value is surfaced as a non-fatal step warning in the trace (code owner_not_found). The projection never invents a property to carry it: an undeclared key would be an unknown property and HubSpot would reject the whole write.

Reading the miss on the record (on_miss_target)

When the people who work in the CRM must see the unresolved owner on the record itself — not only in the event trace — the mapping names the property that receives it:

{"source": "$.payload.owner_email", "target": "hubspot_owner_id", "resolve": "owner_email_to_id", "on_miss_target": "owner_not_found"}

With an email the directory does not know, that event writes hubspot_owner_id: "" and owner_not_found: "unknown@acme.com". With an email it does know, it writes the owner id and owner_not_found: "" — the property is emptied on every hit, so a value left by an earlier miss never survives on the record once the owner resolves. The step warning is unchanged either way: on_miss_target adds a destination, it does not replace the trace signal.

For owner_ids_to_emails the property receives every id that did not resolve, joined with ; ("222;333"), the same list the single aggregated warning carries.

Rules worth knowing before you declare one:

  • It only goes with resolve. Declared alone, together with resolve_identity (whose miss writes no property at all), or pointing at the mapping’s own target, the draft is rejected when you save it. On a mapping that projects into $.canonical.* both keys may be spelled with or without that root: what counts is the property each one ends up writing, so target: "$.canonical.owner" with on_miss_target: "owner" is the same property and is rejected too.
  • The property must exist on the target system. A mapping’s hubspot_type / hubspot_label extras describe its target, so the property named here is not created for you: create it on the portal (or declare it as the target of a mapping on another entity of the same object type) before publishing. The check is blocking: the draft preview reports a missing one as an error (on_miss_target_property_missing) and publish is refused, with a message naming the property, the object type and the portal, listing every missing one at once. Without the property, every event whose owner does not resolve would fail its whole write. If the portal cannot be read, the preview reports the check as unverified and publish is refused until the portal answers. On a custom object whose schema does not exist yet, the schema is created by that same publish, so there is nothing to create on the portal beforehand: declare the property as a mapping target on another entity of the same object type, or publish once without the on_miss_target, create the property and add it back.
  • Give it a property nothing else writes. If another mapping in the same schema writes that property, as its own target or as its own on_miss_target, both write it on every event and the last one in declaration order wins; the draft preview flags it as a warning.

Multi-select owners (owner_ids_to_emails)

A HubSpot multi-select owner property (e.g. a custom “collaborators” property) carries several owner ids joined with ; in one string — "4811992;9999999". The scalar owner_id_to_email treats that whole string as ONE id and misses; resolving it used to need a transform: split (args.index) per fixed slot, capped at however many slots were declared upfront. owner_ids_to_emails resolves the WHOLE list in a single mapping, with no fixed cap:

{"source": "$.payload.object.properties.collaborators", "target": "$.canonical.collaborator_emails", "resolve": "owner_ids_to_emails"}

$.canonical.collaborator_emails (or the equivalent hubspot/post-body target) is then a plain Python/JSON list of the resolved emails — consumable as-is by a downstream field_mappings entry ($.canonical.collaborator_emails as source) or by a plugin ref, exactly like any other list-shaped canonical value. Unresolvable ids are dropped from the list entirely (never a positional null/empty placeholder — there is no fixed slot to leave a hole in), and every miss for that ONE mapping is reported as a single aggregated owner_not_found warning carrying the list of missed ids, never one warning per id. A blank source value resolves to an empty list with no warning.

To write the resolved emails back into an actual HubSpot multi-select property (which needs a single ;-joined string, not a list), chain the existing join transform on a later mapping that reads the canonical list:

{"source": "$.canonical.collaborator_emails", "target": "collaborator_emails_hs", "transform": "join", "args": {"sep": ";"}}

Cross-entity identity resolution

Sometimes the value a HubSpot property needs is another record’s HubSpot object id, while the source system only carries that record’s own business key: a line item points at its catalog product by SKU, a contract points at its customer by customer code. The connector already knows the translation — it is exactly what the identity map stores — but a field mapping cannot do an identity read by itself (the projection is synchronous by design).

resolve_identity is that primitive, declarative and generic over any entity/property pair:

{"source": "$._item.sku", "target": "hs_product_id", "resolve_identity": {"entity": "product"}}

Read as: the value at $._item.sku is the external id of a product; write the HubSpot id of that product into hs_product_id.

FieldTypeRequiredNotes
entitystringyesName of an entity declared in this same config, whose identity map is read. A name matching no entity is rejected at publish (422) and flagged by the preflight as unknown_resolve_identity_entity.
on_missing"omit" (default) or "fail"noWhat happens when the external id has no identity mapping yet.

Where it applies, and the batching. resolve_identity is honored wherever field mappings are projected: a single upsert/create/update/delete/ submit_form, the array actions (upsert_many/create_many/update_many), a body-less post, and a transform step’s canonical projection. On the array path it is resolved per item, and the resolutions of one step are batched: every external id the step references is collected before the per-record loop and resolved with ONE lookup_many per referenced entity, so 90 line items pointing at 90 products cost one identity read, not 90. The index is rebuilt per step (never memoized across the message) so a record created by an EARLIER step of the same flow is resolvable by a later one.

Ordering. resolve_identity applies AFTER source is read and any transform has run — so a transform can normalize the key first (e.g. uppercase on a SKU) and the identity lookup sees the normalized value.

Misses, and how to see them. With the default on_missing: "omit", an external id with no mapping leaves the target property out of the write entirely (not blank: writing an empty string into an id-shaped property is not the same as not touching it), and records a non-fatal identity_not_found step warning carrying the entity, the target and the unresolved external id. That warning lands in the step trace persisted on the sync event and in the dry-run rehearsal, exactly like owner_not_found — so a systematic miss is visible in the dashboard without reading a single server log. The rest of the record still writes: a miss loses one property, never the line. With on_missing: "fail" the step raises a permanent error instead, for a link the write is wrong without.

In a dry run the identity map starts empty and holds only what that same rehearsal wrote, so a reference to a record synced by an earlier event misses there even when the live identity map holds it. The rehearsal says so on the step entry (identity_map_stub_note) instead of leaving you to guess — the same treatment the dry-run already gives its empty owner directory.

A blank reference (the record carries no external id at all) is not a miss: the mapping is skipped silently, no warning — the same blank-in/blank-out convention the owner markers follow.

Tombstoned or superseded rows. An external id CAN have a mapping that is no longer a trustworthy hit: its target HubSpot record was archived/deleted (a target_deleted tombstone), or the row itself was promoted away to a new external id (superseded_by, from promote_identity). resolve_identity never writes either — doing so would point the reference at a dead record or a stale alias, and never follows the superseded_by pointer to the successor automatically (that would need a second, possibly-chained lookup no other identity consumer in the engine performs). Both are governed by the SAME on_missing setting as a genuine miss: omit (default) leaves the property out and records a non-fatal warning — identity_tombstoned or identity_superseded, carrying the entity, the target, the unresolved external id, the dead/stale hubspot_id, and (respectively) the tombstone timestamp or the superseded_by value — while fail raises a permanent error. This mirrors how every other identity consumer in the engine treats the same two markers: skip with an observable trace, never a silent write against dead or stale data.

Reserved-prefix targets. hs_product_id is a NATIVE HubSpot property on line items, so mapping to it is allowed: reserved hs_* / a<appId>_* names are verified as a prerequisite at publish, never created (see the Schema provisioning reference).

Empty vs. absent sources

The general projection rule never changes: a plain field mapping whose source resolves to "" writes "" to target, exactly like any other value — writing an empty string is the canonical way to CLEAR a HubSpot property. None (an absent path) is the only value the not-None guard drops. This rule is load-bearing and does not have a global opt-out: the three mechanisms below are all explicit, per-mapping/per-transform opt-ins for the narrow cases where a source system conflates “empty” and “absent” (Magento sends fooshop_order_id: "" on every cart event, never omitting the key).

They differ in WHAT they do about it, and picking the wrong one is the common mistake:

MechanismScopeOn a blank value
defaultThe whole mapping, on any schemaWrites the declared value to target
skip_if_source_emptyThe whole mapping, hubspot-consumed or post-consumed schemas onlyWrites nothing, leaving whatever an earlier mapping on the same target set
concat’s args.defaultsOne source inside one concat mappingSubstitutes that source’s declared literal inside the joined string
  • concat’s args.defaults (see “Transforms” below) - a source inside args.sources that resolves to None OR "" uses its declared default instead of being dropped. Scoped to that one concat mapping’s own sources.

  • default (FieldMapping field) - when the mapping’s value is None or "" after its transform has run, default is written to target instead. Built for a legacy target with no notion of an absent field, which expects a placeholder in its place:

    {"source": "$._item.properties.firstname", "target": "name", "default": "Missing"}, {"source": "$.canonical.deal_code", "target": "code", "default": "0"}

    Post-transform on purpose: a transform that yields nothing is as blank as a source that did, and the target needs its placeholder either way. It applies wherever field_mappings are read - the HubSpot projection, a post body projection, an each node’s per-element projection and a transform step’s projection into $.canonical.* - which is the one difference from skip_if_source_empty beyond the outcome. null is not a default: it is indistinguishable from not declaring the key, so it never writes null. Declaring default and skip_if_source_empty on the same mapping is rejected when you save the draft - same trigger, opposite outcomes.

    Never put a default on the mapping that feeds a schema’s identity.property. That property is the key every record is matched on, so a placeholder there is not a placeholder: every record whose source is blank is written with the SAME external id, so they all match the same target object and overwrite each other on it, one record left where there should be many. Nothing fails while it happens, and you only see it afterwards in the data. Without the default, a record with no key fails for missing identity instead, which is the signal you want. Preflight reports this shape as default_on_identity_property, so you see it in the preview before you publish.

  • skip_if_source_empty (FieldMapping field, default false) - for an ordinary (non-concat/sum/sum_list) mapping consumed by the HubSpot projection (upsert/create/update/delete/submit_form/*_many, and a body-less post step’s fallback projection). When true, the mapping resolves its source and, if that raw value is None or "", skips the WHOLE mapping before transform even runs - target is left exactly as any earlier mapping on the same target set it. Built for the ordered-fallback pattern (field_mappings are last-write-wins in declaration order, the declarative form of a legacy a || b fallback): a later, more-specific mapping should overwrite the earlier fallback only when it actually carries a value, not when a transform turns its empty source into a non-empty string (e.g. prefix turning "" into "Order "). Not available on a transform step’s from_schema mappings (the canonical projection) - only on a hubspot-consumed or body-less-post-consumed schema.

    {"source": "$.payload.externalObjectId", "target": "dealname", "transform": "prefix", "args": {"prefix": "Cart "}}, {"source": "$.payload.properties.fooshop_order_id", "target": "dealname", "transform": "prefix", "args": {"prefix": "Order "}, "skip_if_source_empty": true}

    With fooshop_order_id: "", dealname stays "Cart <cartId>" instead of being blanked to "Order "; with fooshop_order_id: "O9" present, the second mapping still overwrites to "Order O9" as intended.

Enum option auto-append

When an upsert writes a value to a HubSpot enumeration property whose current option list does not already contain that value, the engine ADDS the option to the property (a HubSpot Schema API PATCH) before writing, then writes the value. This is automatic - there is no config field to enable it; it is keyed on the target property being an enumeration in HubSpot.

This is the one place where the engine mutates the HubSpot schema at runtime - it is otherwise purely declarative - so it is tightly gated:

  • only enumeration-type properties are auto-appended;
  • read-only (modificationMetadata.readOnlyValue) and calculated properties are never appended (the value is still written through to HubSpot, which rejects it if it is not a valid option - same as without this feature);
  • the PATCH is additive only (existing options are preserved, never reordered or removed);
  • an option is appended at most once (idempotent); a value that is already an option triggers no call.

To check the options, the engine reads the portal’s property schema for the object type and reuses it for up to five minutes, so a steady stream of writes to the same portal reads it once rather than on every event. An option the engine appends is visible to the following events right away. An option removed, or an enumeration property created, directly in HubSpot can take up to five minutes to be taken into account; if an append fails, the next event reads the schema again.

The engine does not wait for HubSpot to propagate a freshly-appended option before writing: a blanket delay would hold up every other event. If a write momentarily races a just-appended option that has not propagated, that is an accepted edge.

On a multiple-checkbox property the value is a ;-separated list, and each entry is checked on its own: "a;c" against options a and b appends the option c, never an option named a;c. Empty entries name no option, so the leading ; of the append form below adds nothing to the schema.

Multiple-checkbox properties: adding a value

A value written to a multiple-checkbox property REPLACES the whole stored list. Two flows that each own one option of the same property would overwrite each other.

To add options and leave the others checked, write the value with a leading ;. HubSpot then merges it into the stored list: options already checked stay checked and are not duplicated, and on a record with nothing checked the result is just the options written. The prefix transform builds that value:

{"source": "$.payload.interest", "target": "interests", "transform": "prefix", "args": {"prefix": ";"}, "skip_if_source_empty": true}
StoredWrittenResult
a;ba;b
a;b;ba;b
a;b;c;aa;b;c
(empty);bb
a;bcc
a;b""(empty)

Notes:

  • It costs no extra HubSpot call: the merge happens on HubSpot’s side, in the same write.
  • Keep skip_if_source_empty: true on the mapping: prefix turns an empty source into a bare ;, which carries no option.
  • It works wherever the mapping is projected (upsert, update, create, upsert_many) and in a data object given to update_by_id.
  • The stored value differs from the written one, so on a bidirectional connector do not rely on comparing the two.

There is no equivalent for REMOVING a single option. Unchecking one option means writing the full list without it, so the system that sends the event has to send the complete list, or a plugin has to read the record first.

JSONPath conventions

The field-mapping resolver recognizes exactly these roots:

  • $.payload.* - the raw inbound event body (inbound flows).
  • $.canonical.* - the intermediate canonical shape (federated mode).
  • $.event_type - scalar: the flow’s event_type. Lets one shared mapping on a (possibly shared) entity stamp a per-EVENT literal via a map transform - e.g. {"source": "$.event_type", "transform": "map", "args": {"map": {"new_prodotti": "add_product_in", "change_prodotti": "update_product_in"}}, "target": "state_change_api"} resolves the correct per-event marker for every flow sharing the entity (acme-erp state_change_api). A per-flow const cannot do this when two flows share one entity. Resolves to None when no flow context set it.
  • $.external_id - scalar: the message-level external id from the event envelope (distinct from $.payload), e.g. to feed an identity property.
  • $.occurred_at - scalar: the event envelope’s own event time, as an ISO-8601 UTC string (the ingest instant when the producer sent none). Its reason to exist is conflicts.source_modified_path, where it declares the envelope as an explicit edit-time fallback for a flow whose payload carries no timestamp. Resolves to None when the envelope has no time at all.
  • $<step_id> (no dot) - the full output of a prior step in the same flow.
  • $.<step_id>.* - a nested field on a prior step’s output.

Anything else (for example $.identity.* or $.event) returns None - those are not valid field-mapping roots.

Multi-entity envelopes: payload_root

A source system’s webhook sometimes nests SEVERAL business objects under one event, each under its own key:

{ "payload": { "cliente": {"codice": "C1", "nome": "Acme"}, "ordine": {"numero": "O1", "importo": 250} } }

Without payload_root, $.payload.codice on the cliente entity’s field_mappings reads the TOP-LEVEL payload dict directly - which holds cliente/ordine as keys, not codice. The result is an empty (or wrong) projection, surfaced at write time as identity property 'x' missing from projected properties. data: "$.payload.cliente" on the upsert step does not fix this - data is not read by upsert/create/update/delete today (dead field kept only for update_by_id’s literal body; see step-actions.md).

payload_root declares, once per entity, which payload key its OWN records live under:

{"name": "cliente", "payload_root": "cliente", "schemas": {"hubspot": { "identity": {"strategy": "match_or_create", "property": "ext_id"}, "field_mappings": [ {"source": "$.payload.codice", "target": "ext_id"}, {"source": "$.payload.nome", "target": "name"} ] }}}
{"name": "ordine", "payload_root": "ordine", "schemas": {"hubspot": { "identity": {"strategy": "match_or_create", "property": "ext_id"}, "field_mappings": [ {"source": "$.payload.numero", "target": "ext_id"}, {"source": "$.payload.importo", "target": "amount"} ] }}}

Both entities’ field_mappings keep the plain, un-nested form ($.payload.codice, never $.payload.cliente.codice) - payload_root changes what $.payload MEANS for that entity’s own projection, not the stored mapping strings. At runtime, $.payload.* is resolved against payload[payload_root] instead of the raw payload whenever the step operates on that entity’s own field_mappings (upsert/create/update, delete, submit_form, and a transform step reading that entity’s source schema).

Deliberately scoped to field_mappings/transform sources only. Step-level $... references the operator writes ONCE per step - items, by.value, form_id, body, and conflicts.source_modified_path (see connector.md) - are unaffected and keep reading the raw, un-rooted payload; there is no repeated-path ambiguity to solve for a single reference the way there is for N field_mappings. Write those explicitly against the full envelope, e.g. "source_modified_path": ["$.payload.cliente.updated_at", "$.payload.ordine.updated_at"] (the fallback-list form of source_modified_path), independent of whichever entities declare payload_root.

None (the default, and every existing entity before this field existed) is unchanged behavior: $.payload.* reads the raw payload directly. A payload_root whose key is absent or not an object on a given event is a silent no-op (fails open) - the normal missing-identity error surfaces exactly as before, and dry-run (POST .../draft/flows/{name}/dry-run) appends a suggested payload_root to that error when the event’s payload has a sub-key the entity’s own field_mappings would actually resolve against.

Repeating the root in a mapping of an entity that declares it ($.payload.cliente.codice under payload_root: "cliente") looks for cliente inside cliente and resolves to null on every event that carries the key: the field is never written, and in a target_template token the whole mapping is skipped (see “Computed targets: target_template” above). Preflight reports every source and every target_template token of the entity’s schemas that starts with $.payload.<payload_root>. as a payload_root_repeated warning, with the path to use instead ($.payload.codice) as its suggestion, so you see it in the preview before you publish.

Transforms

A transform node has four variants:

VariantPurpose
TransformNodePathRead a JSONPath, optionally with default and transform / args.
TransformNodeTemplateString template ("{first} {last}"), with safe-brace validation.
TransformNodeConstInline constant value.
TransformNodeConcatConcatenate a list of values.

Named transforms (the strings passed in the mapping’s transform field) are enumerated by GET /api/config-surface; see config-surface.md.

The transform name on every FieldMapping must be either (a) a name in the platform’s built-in transform registry (the transforms list that endpoint returns), or (b) a name declared under config.transforms (the config-level transform registry). When the check runs depends on the form:

  • Pipeline form ("transform": [{"name": ..., "args": ...}], or a single {"name": ..., "args": ...} object): an unknown name, or a name that only works in the flat form (concat, sum, sum_list), is refused when you save the draft (422).
  • Flat form ("transform": "<name>" with a sibling args): a known transform missing a required argument is refused when you save the draft (422). An unknown flat name is accepted by the draft and refused at publish (422).

See flows.md for how field_mappings are consumed inside upsert / create / update steps, and how transform steps populate $.canonical.* from $.payload.* in federated mode.

Last updated on