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
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | Unique within the config. Used by flow steps as entity. |
schemas | dict[str, EntitySchemaSpec] | yes | Keyed by system name (hubspot, canonical, <source>). |
payload_root | string or null | no | Re-roots $.payload.* for this entity’s own field_mappings/transform-step sources. See “Multi-entity envelopes: payload_root” below. |
scope_overrides | list of strings | no | Defaults 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 likefind/upsert.upsert/create/updateaccept no other target, and afindpointed at another target needs its ownurl(see step-actions.md).canonical- the conventional intermediate shape thattransformsteps 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
| Field | Type | Required | Notes |
|---|---|---|---|
object_type | string or null | no | The target system’s object identifier (e.g., "companies", "0-2"). See “object_type normalization” below. |
identity | IdentityConfig or null | no | How to identify existing records. See “Identity” below. |
identity_source_path | string (JSONPath) | no | JSONPath into the inbound payload that yields the external id. Defaults to a fallback chain when omitted — see “identity_source_path default” below. |
identity_response_path | string (JSONPath) | no | JSONPath into the RAW create response body (not a step reference — see “identity_response_path resolution root” below). |
field_mappings | list[FieldMapping] | no | Defaults to []. See “Field mappings” below. |
on_target_deleted | "skip" or "recreate" | no | On 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_guard | IdentityGuard or null | no | On 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.
Fallback search (fallback_search)
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}
]
}
}| Field | Type | Required | Description |
|---|---|---|---|
property | string | yes | HubSpot 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. |
operator | EQ or CONTAINS_TOKEN | no (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. |
source | string ($-path) | yes | Where the searched value is read on the record, resolved exactly like a field mapping source (payload_root applies). |
lowercase | boolean | no (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:
| Rule | Message |
|---|---|
| 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:
$.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.$.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 everyfield_mappings[].source) goes through the rich field-mapping resolver, which understands the$.payload.*/$.canonical.*/$<step_id>.*roots.identity_response_pathgoes 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 nopayload,canonical, orbodykey 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_pathstarting with$.body.with a 422 explaining the correct form. - Preflight (
GET .../preflight) flags the same prefix as anerror-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.
| Value | First event after the deletion | Later 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
recreateabove. - 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": ["..."]
}
}
}| Field | Type | Required | Notes |
|---|---|---|---|
fields | list of strings | yes | HubSpot 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. |
threshold | integer | no (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 namesfor 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 thresholdfor 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
upsertorupsert_manythat 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
thresholdof them differ, nothing is written. On anupsertthe whole event endsquarantinedwith error codeidentity_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 anupsert_manyonly 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:
| Field | Type | Required | Notes |
|---|---|---|---|
source | string (JSONPath) | yes | Read path. Common roots: $.payload.*, $.canonical.*, $.event_type. |
target | string or null | one of target / target_template | Meaning depends on which schema this mapping lives on. See “Which action reads a schema’s field_mappings” below. |
target_template | string or null | one of target / target_template | Computed 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_names | list of strings or null | no | Only 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. |
transform | string, dict, list of dicts, or null | no | Name 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. |
args | dict or null | no | Arguments 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. |
resolve | string or null | no | Owner resolution marker. See “Owner resolution” below. |
on_miss_target | string or null | no | Property 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_identity | object or null | no | Cross-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_empty | boolean, default false | no | Skip this WHOLE mapping (never write target) when its OWN source resolves to None or "", checked BEFORE transform runs. See “Empty vs. absent sources” below. |
default | any JSON value, default null | no | Fallback 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_options | list of {value, label} or null | no | Options 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_type | string | no | HubSpot property type used when publish creates the property (default string). |
hubspot_field_type | string | no | HubSpot field type used when publish creates the property (default text, or select for an enumeration). |
hubspot_label | string | no | Label of the property created at publish. |
transform_args | dict | no | Older 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 byupsert/create/update/delete/submit_formand the*_manyarray actions.targethere is a literal HubSpot internal property name (ortarget_templatefor a computed one). This is the only schema key the hubspot-write actions ever read.- Any other schema, when it is a
transformstep’sfrom_schema— the step reads that schema’s field_mappings and writes each mapping whosetargetstarts with"$.canonical.<key>"into the canonical output. A mapping on that same schema whosetargetis anything else (a bare property name, say) is silently dropped — it is never written anywhere by the transform step (a body-lesspoststep can still consume it directly, see below). - Any other schema, when it is a body-less
poststep’starget— apoststep that declaresentityand no explicitbodyprojectsentity["schemas"][step.target].field_mappingsinto 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”).targethere is a literal key name in the outbound JSON body (no"$.canonical."prefix filtering: EVERY mapping on the schema is applied,transformincluded). This is FALLBACK-ONLY: apoststep that declares its ownbodynever reads the schema’s field_mappings at all, with zero merge between the two — the explicitbodyalways wins outright.datahas no effect on this gate either way: it is a base step field used byupsert/update_by_id, butpostneither declares nor reads it — onlybodypresence andentitydecide.fieldsfurther filters the projection to a subset of targets, same mechanism as upsert/upsert_many’s allowlist, but requiresentity(see step-actions.md). Unlike upsert/upsert_many, apostallowlist is not required to include the schema’s identity property: it filtersfieldsverbatim (no force-include), because that schema’sidentityblock (if any) drives only the create-vs-update HTTP routing, not a written property.notifyis excluded from this whole projection fallback even though it sends over the same HTTP path aspost— anotifystep never projects a body. - Any other schema, when an
eachnode in apostbody names it — apoststep whose explicitbodycontains{"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 ownentity, and this is the one shape that reads a schema through an explicitbody. Each element is bound as$._item.*(and$.payload.*),targetis a literal key name in the projected object, andfieldsinside 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 anytransformstep’sfrom_schema, is not a body-lesspoststep’stargetand is not named by aneachnode, 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-lesspoststep 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 value | Direction | Effect |
|---|---|---|
owner_email_to_id | inbound | Maps the resolved value (an owner email) to the HubSpot owner id. |
owner_id_to_email | outbound | Maps the resolved value (an owner id) to the owner email. |
owner_ids_to_emails | outbound | List-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 withresolve_identity(whose miss writes no property at all), or pointing at the mapping’s owntarget, 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, sotarget: "$.canonical.owner"withon_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_labelextras describe itstarget, so the property named here is not created for you: create it on the portal (or declare it as thetargetof 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 theon_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
targetor as its ownon_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.
| Field | Type | Required | Notes |
|---|---|---|---|
entity | string | yes | Name 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" | no | What 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:
| Mechanism | Scope | On a blank value |
|---|---|---|
default | The whole mapping, on any schema | Writes the declared value to target |
skip_if_source_empty | The whole mapping, hubspot-consumed or post-consumed schemas only | Writes nothing, leaving whatever an earlier mapping on the same target set |
concat’s args.defaults | One source inside one concat mapping | Substitutes that source’s declared literal inside the joined string |
-
concat’sargs.defaults(see “Transforms” below) - a source insideargs.sourcesthat resolves toNoneOR""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 isNoneor""after itstransformhas run,defaultis written totargetinstead. 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
postbody projection, aneachnode’s per-element projection and atransformstep’s projection into$.canonical.*- which is the one difference fromskip_if_source_emptybeyond the outcome.nullis not a default: it is indistinguishable from not declaring the key, so it never writes null. Declaringdefaultandskip_if_source_emptyon the same mapping is rejected when you save the draft - same trigger, opposite outcomes.Never put a
defaulton the mapping that feeds a schema’sidentity.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 thedefault, a record with no key fails for missing identity instead, which is the signal you want. Preflight reports this shape asdefault_on_identity_property, so you see it in the preview before you publish. -
skip_if_source_empty(FieldMapping field, defaultfalse) - for an ordinary (non-concat/sum/sum_list) mapping consumed by the HubSpot projection (upsert/create/update/delete/submit_form/*_many, and a body-lesspoststep’s fallback projection). Whentrue, the mapping resolves itssourceand, if that raw value isNoneor"", skips the WHOLE mapping beforetransformeven runs -targetis left exactly as any earlier mapping on the sametargetset it. Built for the ordered-fallback pattern (field_mappings are last-write-wins in declaration order, the declarative form of a legacya || bfallback): a later, more-specific mapping should overwrite the earlier fallback only when it actually carries a value, not when atransformturns its empty source into a non-empty string (e.g.prefixturning""into"Order "). Not available on atransformstep’sfrom_schemamappings (the canonical projection) - only on ahubspot-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: "",dealnamestays"Cart <cartId>"instead of being blanked to"Order "; withfooshop_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) andcalculatedproperties 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}| Stored | Written | Result |
|---|---|---|
a | ;b | a;b |
a;b | ;b | a;b |
a;b | ;c;a | a;b;c |
| (empty) | ;b | b |
a;b | c | c |
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: trueon the mapping:prefixturns an empty source into a bare;, which carries no option. - It works wherever the mapping is projected (
upsert,update,create,upsert_many) and in adataobject given toupdate_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’sevent_type. Lets one shared mapping on a (possibly shared) entity stamp a per-EVENT literal via amaptransform - 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-erpstate_change_api). A per-flowconstcannot do this when two flows share one entity. Resolves toNonewhen 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 isconflicts.source_modified_path, where it declares the envelope as an explicit edit-time fallback for a flow whose payload carries no timestamp. Resolves toNonewhen 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:
| Variant | Purpose |
|---|---|
TransformNodePath | Read a JSONPath, optionally with default and transform / args. |
TransformNodeTemplate | String template ("{first} {last}"), with safe-brace validation. |
TransformNodeConst | Inline constant value. |
TransformNodeConcat | Concatenate 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 siblingargs): 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.