Loop prevention and conflicts
A bidirectional connector runs both an inbound flow and an outbound flow on the same entity. Without a guard, a write in one direction triggers the other: a HubSpot change pushes to the external system, the external system echoes the update back, and that echo re-enters HubSpot as a new event.
Plugsync does not stop this with one mechanism. It uses four, two per direction, each with its own flag, its own default, and its own trace. This page enumerates all of them and answers the question they exist to make answerable: if something other than plugsync writes to HubSpot, does the connector react?
The short answer
Yes, by default. Nothing on the outbound path looks at who made the write. Your connector reacts to a property change made from the HubSpot UI, from a private app, from a script of yours, or from plugsync itself, all identically.
The two cases where it does not react are both about the value, never about the author:
- the flow has
suppress_echo: true(off by default) and you wrote exactly the values this same connector wrote to that same object within the last 10 minutes; - the flow has
freshness_check: true(on by default) and by the time the event is processed a later write already replaced the value the event carried.
So the recipe for a write that will be seen is: write a value that differs from the last value plugsync wrote, and do not overwrite it again before the event drains. The credential you use is irrelevant.
Why writer identity is not the signal
HubSpot’s journal does not carry a reliable change-source, so the outbound path has no way to ask “was this us?”. Recognition is done on content instead: the write side stores a fingerprint (a hash of the written property values, never the values themselves) and the journal router compares it against the event’s propertyChanges.
Two consequences worth internalizing before designing a bidirectional connector or a test harness:
- Using a different credential or a different OAuth app does not make your write more visible. There is nothing to distinguish.
- Using plugsync’s own credential does not make your write invisible. Only a value match inside the fingerprint window does.
App identity does appear once, on the inbound side only, and its effect is the opposite of suppression: see foreign-only arbitration below.
Outbound guards: HubSpot to your target
| Guard | Flag | Default | Drops | Trace |
|---|---|---|---|---|
| Echo fingerprint | suppress_echo (per flow) | off | journal event whose changed properties match a recent write of this same connector | echo_suppressed in the journal log |
| Outbound freshness | freshness_check (per flow) | on | event whose carried value is no longer HubSpot’s current value | superseded row in conflicts |
Echo fingerprint is the guard that exists specifically to stop the 1:1 bounce of your own inbound write. It is scoped per connector: sibling connectors on the same portal still receive the event. It is deliberately conservative and fails open, so any uncertainty (HubSpot normalized the value, extra properties changed in the same event, unknown payload shape, expired fingerprint, unreachable store) lets the event through. A duplicate bounce is always preferred to a lost event. Full semantics and non-guarantees: flows.md.
Outbound freshness solves a different problem: a journal event can surface minutes after the write it reports, by which time a newer value may already be in HubSpot. Before sending, the guard re-reads the properties the step actually touches and compares them with what the event carried. A mismatch means the event’s value was superseded, and sending it would regress your target below data HubSpot already agrees on. It is on by default because the class of bug it prevents is silent data corruption; an explicit false opts out per flow.
Only properties a user or an integration can write count as evidence. A property HubSpot computes or keeps read-only (in the property definition: calculated, or modificationMetadata.readOnlyValue), such as hs_weighted_pipeline_in_company_currency, is recalculated by HubSpot on its own after a write, so a change to it never marks an event as superseded. The property definitions are read once per portal and object type and reused for a few minutes; if they cannot be read, the guard still ignores HubSpot’s write bookkeeping (hs_lastmodifieddate and similar) and compares every other changed property.
Inbound guards: your system to HubSpot
| Guard | Flag | Default | Effect | Trace |
|---|---|---|---|---|
| Inbound echo | freshness_check (per flow) | on | skips a write whose properties merely echo this connector’s own recent write | superseded row in conflicts, a sync event carrying skip_kind: "echo_suppressed" (skipped unless a sibling flow of the same event wrote) |
| Per-property arbitration | conflicts.strategy | source_wins | decides property by property who wins when HubSpot was also edited | row in conflicts |
| Replay guard | conflicts.reject_stale_source | on under most_recent, off otherwise | discards properties whose target write postdates the event’s business time | discarded_stale row in conflicts |
| Stale-by-history | conflicts.per_property_freshness | on under most_recent, off otherwise | keeps the target value when the incoming one matches an older version in HubSpot’s history | stale_echo reason on the conflict row |
Inbound echo shares the freshness_check flag with the outbound guard, because they are two halves of the same round-trip: the outbound side fingerprints what it sent, the inbound side recognizes it coming back. It is content-based on purpose, so it still catches the echo when the upstream system restamps its own updated_at to “now” on receipt, which defeats any clock-based guard. It requires an existing identity mapping: a brand-new record can never be an echo of a prior write to it.
Per-property arbitration is the conflicts block. Three strategies are available in v3 config:
source_wins(the default): the incoming change always applies, no extra read.target_wins: a contested property keeps its HubSpot value.most_recent: the target wins only if strictly more recently modified than the source. Ties favor the source.
Arbitration is per property, not per record, and only target_wins and most_recent trigger the read-before-write that makes it possible. Field reference, including source_modified_path: connector.md.
When the read fails. The read never decides the record’s fate on its own:
- a client error from HubSpot on the read (for example a
400or409) is not a rejection of your data, so the event is retried like any other temporary failure and is never reported asfailedfor it. This holds whether the connector reads records one at a time or in batches. An authentication or permission error on the read is handled like the same error on the write; - a record that no longer exists on HubSpot (deleted between the identity lookup and the write) is handled exactly like a write that finds the record gone: plugsync looks for another record carrying the same identity value and, if it finds one, links the external id to it and runs the conflict check on it before writing. If there is none, a single event ends
quarantinedwith the reason in its error detail (see sync event statuses), and inside a batch the record isskippedwith atarget_deletedentry in the Conflicts tab. If the record the search returns can no longer be read either (the search can briefly lag behind a deletion), plugsync treats it as if the search had found nothing, without searching again: the event isquarantined, or the recordskippedinside a batch while the rest of the batch is written, unless the entity setson_target_deleted: recreateor the event is a forced replay, in which case the record is created again.
Foreign-only arbitration. target_wins and most_recent arbitrate only properties whose latest HubSpot version was written by someone else: a CRM user, your own tool, another integration. This is the one place where plugsync’s OAuth app id is compared against the writer recorded in HubSpot’s property history. When the latest version was plugsync’s own write, the property is not a conflict at all, it is the echo of the last sync, so it is written without arbitration and produces no conflicts row.
The practical implication for a connector where plugsync is the only writer on the HubSpot side is that zero conflicts is the correct outcome, not an inert engine. The exclusion is logged per property as conflict check: skipped (own write).
Observing a suppression
A suppressed event produces no error. It is not a failure, so nothing surfaces in an error list. Three places make it visible:
Journal log (GET /api/connectors/{connector_id}/journal-log) gives the per-event disposition for the outbound journal path, most recent first. An event dropped by the fingerprint guard appears with status: "echo_suppressed" and the reason echo of a recent write by this connector. An event whose HubSpot record could not be read (a transient HubSpot error, or a HubSpot connection whose credential no longer works) is not delivered to the flows, which would otherwise run on empty properties: it appears with status: "enrichment_degraded" and the reason HubSpot object could not be read, event not delivered to flows to avoid running them on empty properties. Deletion events are exempt, because they do not need the record. A withheld event is not retried automatically, but it can be replayed once the record can be read again, for example after reconnecting HubSpot: POST /api/connectors/{connector_id}/journal-log/replay-degraded re-reads each withheld record and delivers the event to the flows, oldest first, up to limit events per call (default 25, at most 50). The response counts the events replayed, those whose record was deleted in the meantime (object_deleted, not delivered), those that still cannot be read (still_degraded, left for a later replay), and those whose event source was removed, renamed or disabled (source_missing, not delivered, replayable once the source is enabled again); call again while more_pending is true. With freshness_check on (the default), a replayed event whose property changed on HubSpot since the original change is discarded as superseded. The replay delivers the record as it is now, not as it was when the change happened, and each replayed event appears in the journal log as a new entry. Withheld events stay replayable for 7 days, the retention of the journal log. Without a replay, the next change to the same record produces a new event. The same response carries a backlog indicator (pending_count, oldest_pending_queued_at), which distinguishes the other common misreading: an event that looks lost but is only queued behind a backlog. This endpoint has no dashboard view today, query it directly.
Conflicts (GET /api/connectors/{connector_id}/conflicts, and the Conflicts view of the connector in the dashboard) records every discard the value-based guards make, so a suppression is never invisible even though no write happened:
resolution: "superseded"- the outbound freshness guard found the event’s value already replaced, or the inbound echo guard recognized the write as this connector’s own recent POST coming back.resolution: "discarded_stale"-reject_stale_sourcerejected a replayed or out-of-order event.resolution: "identity_superseded"- a lateupsert/update/deleteresolved an identity mapping thatpromote_identityhad already renamed away, so the write was skipped instead of clobbering the promoted record. Platform behavior, no config field governs it. Do not confuse it withsuperseded_source, which is a promotion declined byon_superseded: skip: step-actions.md lists the wholepromote_identityfamily and the naming asymmetry with$step.outcome.reason: "stale_echo"on a property of the conflict row -per_property_freshnesskept the target’s value because the incoming one matched an older version of that property’s history.
Connector logs carry the outcomes that are decisions rather than discards: an echo suppressed on the journal path, and the per-property conflict check: skipped (own write) line.
What you can do with a conflict row once you have found it is covered in acting on a conflict.
Acting on a conflict
A conflict row is a record of a decision plugsync has already applied. The arbitration picked a winner property by property and wrote the result, or a guard discarded the event, before the row existed. Every row plugsync records is therefore created with status: "resolved", a resolution (source_wins, target_wins or merged for an arbitration, or the value naming the guard that acted, such as those listed above) and a resolved_by naming the rule that decided it, for example policy:target_wins, policy:most_recent, policy:freshness_check or policy:reject_stale_source. Nothing is waiting for you to unblock it.
Two actions remain, and they do very different things:
- Reconcile is the only action with an effect outside plugsync. The side that lost (typically your external system, after
target_winskept HubSpot’s value or a stale event was discarded) still holds the losing value, and nothing pushes the winner back to it on its own. Reconcile sends HubSpot’s current value to it through the connector’s outbound flow. - Resolve (one row, or several in bulk) records a decision on a
pendingrow. It writes nothing to HubSpot or to your system. The current runtime never createspendingrows; a connector can still hold some recorded by an earlier version of plugsync, and these endpoints exist to close them.
Endpoints and credentials
| Request | Body | Success response | Effect |
|---|---|---|---|
GET /api/connectors/{connector_id}/conflicts/{conflict_id} | none | 200, the conflict | none |
POST /api/connectors/{connector_id}/conflicts/{conflict_id}/resolve | {"resolution": ..., "merged_data": ...} | 200, the updated conflict | the row only |
POST /api/connectors/{connector_id}/conflicts/resolve-bulk | {"conflict_ids": [...], "resolution": ...} | 200, {"resolved": <count>} | the rows only |
POST /api/connectors/{connector_id}/conflicts/{conflict_id}/reconcile | none | 200, the conflict with reconciliation_status: "pending" | your external system, via the outbound flow |
They take the same credentials as the rest of the connector API: Authorization: Bearer with an org API key (ps_org_...) or the token of a dashboard session. Any member of the organization can call them; the owner role is not required. A connector that does not belong to your organization answers 404 with Connector not found, and a conflict id that does not belong to that connector answers 404 with Conflict not found. resolved_by records the email of the user who resolved, or api-key@system when the call came with an org API key.
Reading one conflict
GET /api/connectors/{connector_id}/conflicts/{conflict_id} returns the same object as one item of the list:
| Field | Meaning |
|---|---|
id, connector_id | the conflict and its connector |
entity_type, external_id, store_id | the record, as your system identifies it |
source_data | the incoming value of each contested property |
target_data | HubSpot’s side of each contested property. On rows recorded by the current runtime each property is an object: value, timestamp (when HubSpot last changed it, when known), source_type (who changed it, from HubSpot’s property history, when known) and, when a rule beyond the plain strategy decided, reason (for example stale_echo) |
source_modified_at, target_modified_at | the two times the arbitration compared, when known |
status | resolved or pending |
resolution, resolved_data | the decision and the values that stand after it |
resolved_at, resolved_by | when, and by which rule or which caller |
reconciliation_status | none (never reconciled), pending (reconcile requested, in progress) or done (your system was written) |
created_at, updated_at | timestamps of the row |
GET /api/connectors/{connector_id}/conflicts accepts status and entity_type as filters, plus limit (default 50, at most 200) and offset. Because every row the runtime records is created as resolved, filter on status=resolved (or omit the filter) to see them.
Reconciling your system with HubSpot
POST /api/connectors/{connector_id}/conflicts/{conflict_id}/reconcile takes no body. plugsync reads the record from HubSpot as it is now (the contested properties, which are the keys of target_data, plus the enrich_properties of the connector’s HubSpot event source) and runs the connector’s outbound flows that would react to a change of those properties on that record, exactly as if the change had just been made in HubSpot.
What reaches your system is HubSpot’s current value, not the row’s resolved_data and not the snapshot in target_data. If someone changed the property in HubSpot after the conflict, your system receives that newer value. Carrying the current value is also what lets the event pass the outbound freshness_check instead of being discarded as superseded. suppress_echo is not applied to this event: it is an explicit request to push HubSpot’s value out, not a journal event that might be the echo of plugsync’s own write.
The response is the conflict with reconciliation_status: "pending". It then moves on its own:
doneonce an outbound flow actually wrote to your system;- back to
nonewhen the flows ran but wrote nothing (awhencondition skipped them, for example) or the event failed. You can reconcile again.
A reconcile still pending after a while can be requested again, which sends a fresh attempt; once done, the row refuses a second reconcile. The errors:
| Status | detail | Cause |
|---|---|---|
400 | Only a resolved conflict can be reconciled | the row is pending: resolve it first |
400 | Reconciliation already done | reconciliation_status is already done |
422 | the reason in plain words | plugsync could not run the outbound side for this record: the row records no contested property, the record has no identity mapping (so its HubSpot object is unknown), the connector has no enabled HubSpot event source or no published config, HubSpot is not connected for the connector (or the organization has several portals and none is bound to it), or no outbound flow of the connector reacts to those properties. reconciliation_status stays none |
Resolving a pending conflict
POST /api/connectors/{connector_id}/conflicts/{conflict_id}/resolve sets the decision on one pending row. The body is {"resolution": "source_wins"}, {"resolution": "target_wins"}, or a merge that carries the values to keep:
{"resolution": "merged", "merged_data": {"phone": "+39 02 1234567", "city": "Milano"}}The row becomes status: "resolved" with the given resolution, resolved_at set to now, resolved_by set to the caller and resolved_data set to source_data (for source_wins), target_data (for target_wins) or your merged_data (for merged). merged_data is stored as you send it; nothing checks its keys against the contested properties. The response is the updated conflict.
Resolving writes nothing to HubSpot or to your system and re-runs no flow: it closes the row. To make the decision real, act on the side that needs it. If HubSpot must change, write the value there, by hand or by sending the event again. If your system must change, reconcile the row once it is resolved, which pushes HubSpot’s current value.
| Status | detail | Cause |
|---|---|---|
400 | Conflict already resolved | the row is not pending (every row the current runtime records) |
400 | Invalid resolution | resolution is not source_wins, target_wins or merged |
400 | merged_data required for merged resolution | merged without merged_data, or with an empty object |
422 | validation error | the body is not a JSON object with a resolution string |
Resolving several conflicts at once
POST /api/connectors/{connector_id}/conflicts/resolve-bulk applies one decision to many rows:
{"conflict_ids": ["9b2f6c1e-4d1a-4c7e-9a51-2f0e8d3b7c10", "c41a0d5e-8b7f-4f12-a3c6-61d9e2b0f4a8"], "resolution": "target_wins"}Only source_wins and target_wins are accepted; any other value answers 400 with Bulk resolve only supports source_wins or target_wins (a merge is per record, so it goes through the single-row endpoint). Each row gets resolved_data from its own source_data or target_data. Ids that are not pending, or that belong to another connector, are skipped without an error: the response {"resolved": 2} counts the rows actually changed, so compare it with the number of ids you sent. Like the single-row endpoint, it writes nothing to HubSpot or to your system.
From the dashboard
The same actions are in the connector workspace, under Issues, tab Conflicts.
- The list opens on the
pendingfilter. Rows recorded by the current runtime areresolved, so switch toresolvedorAllto see them. The columns are Entity, External ID, Store, Status, Source realignment and Created. - Source realignment is reconcile. On a
resolvedrow it shows a Reconcile source button; after the click the notification reads “Reconciliation started - the source will be updated shortly” and the cell shows Pending with a Retry button, then Done once your system was written. When reconcile cannot run, the notification shows the API’sdetail. On apendingrow the cell shows-: resolve it first. - Bulk resolve: select rows with the checkboxes, then Resolve as Source or Resolve as Target (the count of selected rows is in the label). The notification reports how many rows were resolved, which counts only the rows that were still
pending. - View opens the conflict detail: each property side by side under Source (External) and Target (HubSpot), differing values highlighted. On a
pendingrow each differing property has anS/Ttoggle (source by default) and three buttons: Accept Source and Accept Target call resolve withsource_winsandtarget_wins; Apply Merge calls resolve withmergedand amerged_databuilt from your toggle choices. On aresolvedrow the detail shows who resolved it and when, and the same Source realignment controls as the list.
As with the API, resolving from the dashboard changes only the row. Reconcile is what updates your system.
What is not a loop guard
- The credential or app you write with. Covered above: on the outbound path there is nothing to attribute a write to.
- A timestamp comparison on the identity mapping. Earlier versions of this page described a loop guard that compared the last sync time with each side’s last modification time on the identity-mapping record. Nothing does that today: no v3 config field turns it on, and those timestamps are never maintained. The guards on this page are the ones that run.
- The v2
bidirectional.conflict_strategyblock, including itsmanualstrategy. It has no v3 reader. Conflict behavior in v3 is configured only through the top-levelconflictsblock.
Testing a bidirectional connector without fooling yourself
The failure mode this page exists to prevent is a test that quietly proves nothing. When you write to HubSpot by hand to exercise the HubSpot-to-target direction:
- Change the value. Rewriting the value plugsync already wrote is the one case the fingerprint guard is designed to swallow (when
suppress_echois on), and a no-op change may not produce a journal event at all. - Give it more than the 10 minute fingerprint window if you must reuse a value the connector recently wrote.
- Check the journal log before concluding the event was dropped. A backlog looks exactly like a drop from the outside.
- Do not assume your own writes are always filtered, or that they always come back. Both guards are best-effort and fail open by design.
- authoring-a-flow.md - end-to-end inbound and outbound walkthroughs.
- flows.md -
suppress_echo,freshness_checkandnarrow_outbound_projectionfield reference. - connector.md - the
conflictsblock field by field. - reliability/index.md - retries, failures and replay.
- index.md - guide overview and prerequisites.