Skip to Content
ReferenceConfig V3Draft & publish

Draft -> publish workflow

Every config write produces a draft. The draft becomes a published version only when you explicitly click Publish (or POST /publish). Only a published version ever runs; your draft is never executed.

The model

+----------------+ +-----------------------+ +-----------------------+ | | PATCH | | POST | | | Dashboard +--------> draft +--------> version N+1 | | AI agent |/draft | (one per connector, |/publish| (append-only history,| | CLI | | mutable) | | exactly one active) | | MCP server | | | +----------+------------+ +----------------+ +-----------------------+ | v the running connector (usually switches over in a few seconds)

Invariants:

  • Single write path. Every writer (dashboard, AI agent, CLI, MCP) goes through PATCH /api/connectors/{id}/draft or one of the granular helpers below. No endpoint mutates state by side effect. Writing an event source’s definition through /sources is the same path seen from another door: it lands in the draft and answers 202 (see EventSources).
  • The draft never runs. Nothing you save is in effect until you publish it, so an unfinished edit cannot reach HubSpot or your target system.
  • Atomic publish. Either version N+1 exists in full or nothing changed: there is no half-published config. The running connector usually picks the new version up within a few seconds. GET /api/connectors/{id}/versions/active tells you which version is live, GET /api/connectors/{id}/versions the whole history.
  • Immutable history. Every published version is strictly immutable.

Endpoints

EndpointVerbPurpose
/api/connectors/{id}/draftPUTReplace the entire draft config.
/api/connectors/{id}/draftPATCHPartial draft update: RFC 7396 merge patch over the current draft, same semantics as the source PATCH (see event-sources.md).
/api/connectors/{id}/draft/entities/{name}PUTUpsert a single entity in the draft.
/api/connectors/{id}/draft/entities/{name}DELETERemove a single entity from the draft.
/api/connectors/{id}/draft/entities/{name}/discardPOSTRevert one entity in the draft back to the published version.
/api/connectors/{id}/draft/flows/{name}PUTUpsert a single event flow in the draft.
/api/connectors/{id}/draft/flows/{name}DELETERemove a single event flow from the draft.
/api/connectors/{id}/draft/sources/{name}PUTUpsert a single event source definition ({type, config}) in the draft.
/api/connectors/{id}/draft/sources/{name}PATCHMerge into one source definition’s config: RFC 7396 merge patch.
/api/connectors/{id}/draft/sources/{name}DELETERemove a single source definition from the draft. 409 while a flow of the same draft still names it.
/api/connectors/{id}/draft/flows/{name}/dry-runPOSTRun a draft flow against stub clients; no external system is touched. An associate step whose endpoints were not upserted in the same rehearsal reports not_verifiable (the stub starts with no identity mapping), not an error; the remaining steps keep rehearsing. A plugin step (plugin.logical_name) is never invoked, live or via any fallback path — it is reported as not_executed: true with a not_executed_reason, and the rehearsal keeps going past it (see plugins/dev-loop.md for why, and plugsync plugin invoke for actually exercising the handler). Any step that hit the stub HTTP client (e.g. a post/identity-aware upsert to a non-HubSpot target) is flagged stubbed_external_response: true — its response never came from the real target; a source’s config.outbound.dry_run_response shapes that stub’s body (see entities.md, “identity_response_path resolution root”). A delete step against a REST destination resolves the id to delete from the identity map, which is empty here: the rehearsal simulates that mapping so the request (url, method, headers) is actually exercised, and flags the step simulated_identity: true with a simulated_identity_note. The external id in that url is invented (prefix dry-run-external-id-) and a green outcome is no proof the record is mapped — at runtime an unmapped record makes the same step a silent no-op, which the trace still reports on its own as reason: "no_identity_mapping" without the flag.
/api/connectors/{id}/draft/settingsPATCHPartial update of config.settings: RFC 7396 merge patch.
/api/connectors/{id}/publishPOSTPublish the current draft as the next version.

The publish body is optional and every field in it is optional: change_summary (a note stored with the new version), force (below), and the deprecated new_sources/delete_sources (see further down). The version the draft was started from is not part of the request: the draft tracks it as base_version (shown in GET /draft) and the publish checks it, see “Concurrency” below.

The read endpoints behind the dashboard’s editor (the draft itself, the preview of a publish, preflight, the version history and the field mapping suggestions) are in Reading the draft and the history. When it is enabled for your organization, a publish that would create HubSpot properties or pipelines also needs a publish review.

Forcing a re-publish on an identical draft

POST /publish normally answers no_changes: true and creates nothing when the draft is byte-identical to the active version. That check compares authored text, but a publish also resolves values that can drift on their own without any draft edit: a plugin promoted to a new version, a per-connector plugin override changed, a HubSpot property removed by hand on the portal since the last publish. Passing force: true in the body publishes a new version anyway, re-running the whole pipeline (plugin resolution, HubSpot schema provisioning, warnings) against the config as it resolves right now; the new version’s change_summary defaults to “Re-publish (forced)” when you do not supply one and there was no organic draft change.

GET /preview’s resolved_changes list tells you whether a forced re-publish would actually change anything before you use it: it diffs the active version’s already-resolved plugin coordinates against what publishing now would resolve, a comparison the regular changes list intentionally skips. The dashboard exposes the same forced re-publish without the API or the CLI: the Review changes drawer shows that list and, when the draft has no changes of its own, a Re-publish button that applies it (see “Dashboard walkthrough” below). See plugins/promotion.md for the plugin-specific cases this covers, and when the platform already refreshes things for you without a re-publish.

Reading the draft and the history

The read side of the workflow is what the dashboard’s editor and its Review changes drawer are built on. None of these endpoints changes the draft or the active version, and all of them are open to any member of the organization and to the organization API key; a connector of another organization answers 404, like one that does not exist.

EndpointVerbPurpose
/api/connectors/{id}/draftGETThe draft, with its revision counters.
/api/connectors/{id}/previewGETWhat publishing the draft now would change, compared with the active version.
/api/connectors/{id}/preflightGETAdvisory checks of the draft, including checks against the HubSpot portal.
/api/connectors/{id}/publish-reviewGETThe HubSpot properties and pipelines the publish would create (see Publish review).
/api/connectors/{id}/versionsGETEvery published version, newest first, without their config.
/api/connectors/{id}/versions/activeGETThe version that is running. 404 before the first publish.
/api/connectors/{id}/versions/{version}GETOne published version, with its config.
/api/connectors/{id}/versions/{from}/diff/{to}GETWhat changed between two published versions.
/api/connectors/{id}/draft/entities/{name}/field-mapping-suggestionsGETSuggested field mappings for one entity of the draft.

GET /draft

{ "config": { "version": "3.1", "entities": [], "flows": [], "settings": {}, "sources": [] }, "base_version": 4, "draft_version": 17, "updated_at": "2026-10-14T09:02:11.530000+00:00", "updated_by": "jane@example.com", "has_changes": true, "direction": "inbound", "flow_directions": { "order_created": "inbound" }, "warnings": [] }
FieldMeaning
configThe draft config, as the next publish would take it.
base_versionThe version that was active when the draft was started; the publish checks it (see Concurrency).
draft_versionThe draft’s revision, raised by every write. Pass it as expected_version on a write, and as draft_version to confirm a publish review.
updated_at, updated_byThe last write and who made it.
has_changestrue when the draft differs from the active version.
directioninbound, outbound or bidirectional, derived from the flows; null while the draft has no flows. flow_directions gives the same reading per flow.
warningsNon-blocking findings on the saved draft: today only the config size.

The first GET on a connector that has no draft yet creates an empty one, which declares the connector’s existing sources.

GET /preview

{ "has_changes": true, "changes": [ { "type": "entity_modified", "detail": "Field mappings changed", "entity": "contact", "event_flow": null, "source": null }, { "type": "event_flow_added", "detail": "New event flow", "entity": null, "event_flow": "order_created", "source": null } ], "resolved_changes": [] }

Each entry of changes has a type, a human-readable detail and the name of what it concerns in entity, event_flow or source. The type names the part of the config and what happened to it: entity_, event_flow_, source_, conflicts_, pipelines_ and ingress_profile_ followed by added, modified or removed, and settings_modified. Treat the list of types as open: a new part of the config brings its own.

resolved_changes lists what a publish would resolve differently from the active version without any draft edit (see Forcing a re-publish). It is never counted in has_changes, and it is empty before the first publish.

GET /preflight

{ "errors": [], "warnings": [ { "severity": "warning", "code": "enrich_properties_gap", "message": "Flow 'contact_updated' reads $.payload.object.properties.vat_number but source 'hubspot' enrich_properties does not list ['vat_number']: ...", "field": "flows[contact_updated].steps", "suggestion": null, "entity": null, "event_flow": "contact_updated", "reauthorize_scopes": null } ], "infos": [] }

Preflight always answers 200: every problem it finds is a finding, sorted into errors, warnings and infos by severity. Each finding names its code, a message, the field and the entity or event_flow it concerns, and when there is one a suggestion. reauthorize_scopes is set only on findings about the HubSpot connection’s scopes: the scopes to grant when reconnecting.

Preflight is advisory: POST /publish does not read it. A few of its errors describe a check that the publish runs on its own, so the publish will be refused for the same reason: a flow or step naming a source or entity the config does not declare, or a config over the size limit. plugsync push --publish runs preflight first and does not publish when it reports an error.

?credential_id=<id> runs the portal checks against another HubSpot connection of your organization instead of the one the connector uses, for example before moving the connector to another portal. Nothing is changed: the connector stays on its portal. A credential of another organization answers 404 ("Credential not found"), and one that is not a HubSpot connection answers 422 with credential_not_hubspot_oauth.

Versions

GET /versions lists every published version, newest first:

[ { "id": "c0a8...", "version": 5, "published_at": "2026-10-14T09:10:02.411000+00:00", "published_by": "jane@example.com", "change_summary": "Map the VAT number", "is_active": true } ]

GET /versions/{version} and GET /versions/active return the same fields plus the version’s config. An unknown version answers 404 with "Version N not found".

GET /versions/{from}/diff/{to} compares two published versions and answers {"from_version", "to_version", "changes"}, where changes has the same entries as in GET /preview. from and to must be two different versions that both exist: otherwise the answer is 404 with "One or both versions not found". To see what the draft would change, use GET /preview instead.

Field mapping suggestions

GET /draft/entities/{name}/field-mapping-suggestions?hubspot_object_type=contacts suggests, for every field of the entity’s source payloads, the HubSpot property to map it to. It is what the field mappings editor shows next to each field. It never writes the draft: accepting a suggestion is a normal draft write.

{ "entity": "contact", "hubspot_object_type": "contacts", "source_names": ["shop_webhook"], "suggestions": [ { "source_field": "email", "hubspot_property": "email", "confidence": 0.95, "reasoning": "'email' matches HubSpot property 'email' exactly.", "create_custom_property": false, "suggested_property_name": null }, { "source_field": "loyalty_tier", "hubspot_property": null, "confidence": 0.0, "reasoning": "No existing HubSpot property matches 'loyalty_tier'. Suggest creating a custom property 'loyalty_tier'.", "create_custom_property": true, "suggested_property_name": "loyalty_tier" } ], "message": null, "error": null }
  • hubspot_object_type is required: the HubSpot object whose properties are matched, read from the portal the connector is connected to.
  • The source fields come from the sample payloads captured when events arrive, taken from the sources whose flows write the entity (source_names). When no flow writes the entity yet, the samples of every source are used and source_names is ["*"].
  • A field is matched by exact name (confidence 0.95), then by known synonyms (0.85), then by name similarity (from 0.3 to 0.8). A field with no match has confidence 0, hubspot_property: null, create_custom_property: true and a suggested_property_name.
  • Samples are read first, then the HubSpot connection. With no field to suggest from, suggestions and source_names are empty and message says why: no sample captured yet on the connector, samples only for sources that do not feed this entity, or payloads with no mappable fields. Send a test event through one of the entity’s sources and ask again.
  • When the organization has no HubSpot connection, the answer is still 200, with suggestions and source_names empty and error set.
  • An entity that is not in the draft answers 404.

Body contract for granular writes

Every write below expects its payload flat: the config/patch object itself, directly as the request body — no top-level wrapper key, the same convention as POST /sources. This is the one place that contract is documented.

EndpointBody shapeNotes
PUT /draft<full v3 connector config>Replaces the draft wholesale.
PATCH /draft<RFC 7396 merge patch>Deep-merged into the current draft; null values delete keys.
PUT /draft/entities/{name}<entity object>, with an optional top-level auto_flow: {"source": ..., "event_type": ...} sibling keyauto_flow is only honored when the org’s simple_mode_autoflow flag is on; omit it otherwise. It is pulled out of the body before the rest is saved as the entity config — never persisted as an entity field.
PUT /draft/flows/{name}<event flow object>
PUT /draft/sources/{name}{"type": ..., "config": {...}}The name comes from the path, not the body.
PATCH /draft/sources/{name}{"config": <RFC 7396 merge patch>}Merged into that source’s declared config only.
PATCH /draft/settings<RFC 7396 merge patch>Merged into config.settings only.
POST /draft/flows/{name}/dry-run{"input_event": <event payload>, "event_type": <string> | null}event_type previews a wildcard flow ("cliente.*") against a concrete event type; omit for non-wildcard flows. Unrelated to the flat/wrapper contract above — this endpoint isn’t a config write.
DELETE /draft/entities/{name}, DELETE /draft/flows/{name}, DELETE /draft/sources/{name}, POST /draft/entities/{name}/discardnone

Example — PUT /draft/entities/company:

{ "name": "company", "schemas": { "hubspot": { "object_type": "companies", "identity": {"strategy": "match_or_create", "property": "domain"}, "field_mappings": [] } } }

Legacy wrapper (deprecated)

Earlier releases required the payload wrapped under a single top-level key — inconsistent with POST /sources, which never did. The wrapper key is not the same on every endpoint:

EndpointLegacy wrapper key
PUT /draftconfig
PATCH /draftpatch
PUT /draft/entities/{name}config
PUT /draft/flows/{name}config
PATCH /draft/settingspatch

That wrapper still works during the transition (removal tracked separately, not yet scheduled) when it matches the endpoint’s own key: sending it does not fail, but the response carries a Deprecation: true header (plus an explanatory X-Plugsync-Deprecation header) and the server logs a warning. New integrations should use the flat shape above; existing ones sending the correct wrapper keep working unchanged.

A body is only recognized as the legacy wrapper when its top-level keys are exactly the wrapper key (plus auto_flow on the entity endpoint) — no other fields. ConnectorConfigV3 / EntityV3 / FlowV3 all allow unrecognized extra fields (extra="allow"), so a flat body that happens to declare its own extra field literally named config or patch alongside its real fields is unambiguous and stays flat; only a body with nothing else in it is treated as wrapped.

Trap: the PUT-shaped {"config": ...} wrapper does not carry over to PATCH /draft. PATCH /draft’s wrapper key is patch, not config, so {"config": {...}} sent there is not recognized as a wrapper at all — it is parsed as a flat merge patch whose one top-level key happens to be literally named config. Merging that into the draft used to nest it silently (draft.config.config) with no error at all. It now fails loudly instead: after the merge, any top-level key besides version/entities/flows/sources/settings/conflicts/pipelines/ingress_profile (plus the transforms escape hatch) is rejected with a 422 unknown top-level config key(s) ['config'], whose message lists the accepted keys. The fix is to send the config flat to PATCH /draft (the canonical shape above), never wrapped under config.

A validation error inside the payload (e.g. an unsupported version) keeps its own, separate 422 message either way — the flat/wrapper distinction only changes how the body is parsed, never how it’s validated.

What a publish does to the connector’s event sources

A connector whose config declares sources (see EventSources) has its sources reconciled with that declaration by every publish, right after the new version goes active. The declaration is the desired state:

The config declaresThe connector hasThe publish
a namenothing under itcreates the source, generating a secret when the type needs one
a name with a different configthat sourcerewrites its config
nothing under a namea source under itdeletes it, with its secret (what happens to its read position depends on the type, below)
exactly what the connector hasthe samenothing at all

enabled, the secret and the runtime state of a source that survives are never written by a publish: they are not in the config and a publish cannot touch them. Nothing here is a separate step you can half-apply either — the reconciliation is part of the publish transaction, so either version N+1 exists with its sources reconciled or nothing changed.

The response says what it did:

{ "version": 7, "created_sources": [{"name": "shop_webhook", "secret": "<generated once>"}], "updated_sources": ["crm_api"], "deleted_sources": ["legacy_poller"], "skipped_sources": [] }

created_sources is the one and only time a generated secret is readable; updated_sources and deleted_sources are bare names. skipped_sources is always empty for a connector that declares sources — see the deprecated shortcuts below.

Republishing the same config reconciles nothing, so a publish is safe to repeat. A source someone deleted by hand outside the config comes back at the next publish, because the config still declares it.

When a source’s credential does not resolve

A source names its credential by name, so the reference can go stale after the source was created: the credential renamed, deleted, or replaced by one of another type. Every publish looks each reference up again:

Source typeReferenceCompatible credential types
restconfig.outbound.credential_nameapi_key, bearer_token, login, oauth2_client_credentials
pollerconfig.inbound.credential_nameapi_key, bearer_token, oauth2_client_credentials
fileconfig.inbound.credential_namesftp
resteach entry of config.outbound.secret_headersapi_key
pollereach entry of config.inbound.secret_headersapi_key

A reference that does not resolve to exactly one compatible credential of your organization fails the publish with a 422 and no new version:

{ "detail": { "error": "credential_not_found", "credential_name": "crm-api-key", "available": ["crm-api-key-v2", "erp-token"], "path": "config.outbound.secret_headers.X-Api-Key", "source": "crm_api" } }
errorMeaning
credential_not_foundNo credential of your organization has that name.
credential_type_mismatchThe name exists, but its type is not one this reference can use (for example an sftp credential on a rest source).
credential_ambiguousMore than one credential has that name. Credential names are unique within an organization, so you should not see it.

available lists, sorted, the names of your credentials of a compatible type: the real choices for the reference. path is there only when the broken reference is a secret_headers entry, and names it; for credential_name it is left out. source is the source the reference belongs to. When several references are broken, the response describes the first one, and preflight lists them all.

A broken reference blocks the publish when the publish creates the source, or when the source stays enabled and in use after it:

  • a source the publish deletes (its name gone from sources, or listed in delete_sources) is not checked at all;
  • a disabled source, and a rest source that no flow triggers on, posts to or grants a plugin auth on, come back as a warning with the same error code in the publish response’s warnings, explaining why the publish went through. Repair the reference before you enable or wire up the source;
  • a source the publish has to create always blocks, whatever its flows do, because the creation itself needs the credential. The message adds This source does not exist yet: publishing creates it, and the creation needs '<name>' to resolve. Create that credential first.

GET /preflight and the dashboard’s Review changes drawer report the same findings before you publish.

Removing a source that has been running

Removing an entry deletes the source outright. What happens to the position it had reached is not the same for every type, and the difference decides whether re-declaring the same name later is safe:

  • A position kept on the source itself — a HubSpot journal offset, a full-extract walk — is deleted with it. A source declared again later under the same name starts over from scratch.
  • A poller’s incremental watermark is kept apart, keyed by the source name, and is not deleted with the source. Declaring a source under that same name again resumes from where the old one stopped and silently skips everything the feed published in between. Use a different name when you want a fresh start.

Preflight warns before you publish either case — source_removed_with_state, naming the source, the position it is at and which of the two above applies — and does not block: it is a real thing to do, just not one to do by accident.

new_sources and delete_sources (deprecated 2026-09-10)

POST /publish accepts two lists that predate sources in the config: new_sources (source specs to create with this publish) and delete_sources (names to remove). They still work, and on a connector that declares sources they are shorthand for editing the draft: the entries are folded into the declaration before anything else runs, so the change outlives the call instead of being undone by the next publish.

Declare the source in the config instead. The shortcuts existed to break a circular dependency — a source whose own validation requires a published flow, and a flow whose source must already exist — that the config document does not have: a source and the flow that reads it are published together because they are in the same document.

skipped_sources in the response belongs to those shortcuts and is deprecated with them. It listed the new_sources names left untouched because they already existed; with a declaration an existing name is an update, so it comes back in updated_sources and skipped_sources stays empty.

A name listed in both new_sources and delete_sources is refused before anything else runs, with a 422 naming every such name:

{"detail": "names cannot appear in both new_sources and delete_sources: ['crm_api']"}

Dashboard walkthrough

After any draft write (UI edit, agent edit, script setup), the connector workspace shows an “unpublished changes” banner:

Unpublished changes banner

Dashboard UI as of 2026-07-11; controls may have moved by the time you read this. The underlying workflow is stable.

Click Review changes to see a diff between your draft and the currently active version:

Review changes modal

When a plugin referenced by a flow has drifted from what the active version embeds (typically after a promote), the drawer also shows a separate “Publishing now would also resolve to different values” panel, even when the draft itself has no changes to publish. When the draft has no changes of its own, that panel carries a Re-publish button: it publishes the drift as the next version (the same forced re-publish as force: true on the API or plugsync push --publish --republish, with the same confirmation step as a regular publish - see “Forcing a re-publish on an identical draft” above). With no draft changes there is no “Review changes” to click, so the status bar at the bottom of the workspace signals it instead: it reads Re-publish available and offers a Review re-publish button that opens the same drawer. When the draft does have changes, no extra action is needed: the regular Publish re-resolves them too.

Click Publish to commit the new version. The banner disappears and the version label updates:

Published state

Config size limit

A published version is compiled into one runtime document: the config itself plus the manifest generated from each of its flows, so every flow counts twice. That compiled size has a limit of 350 KB. The size of a config grows with entities, field mappings, flows and steps; a typical connector stays well below 100 KB.

  • From 80% of the limit (280 KB) the draft is still saved normally, and every draft response (GET, PUT and PATCH on /draft and on its granular endpoints) carries a warning in warnings:

    { "warnings": [ { "code": "config_size_near_limit", "message": "The compiled config is 301.2 KB of the 350.0 KB limit (86%). ...", "size_bytes": 308429, "limit_bytes": 358400 } ] }

    GET /api/connectors/{id}/preflight reports the same finding, and so does the warnings[] of a successful POST /publish.

  • Over the limit the draft is still saved (the warning code becomes config_too_large, and preflight reports it as an error), but the publish is refused with HTTP 422 and no version is created. The active version keeps running unchanged:

    { "detail": { "error": "config_too_large", "message": "The compiled config is 362.8 KB of the 350.0 KB limit (104%). It cannot be published: ...", "size_bytes": 371507, "limit_bytes": 358400 } }

The size is measured on the config as it will be published, so the number you see in the draft warning is the number the publish checks (the publish also embeds the resolved plugin coordinates, a few hundred bytes per plugin step).

What to do when a config approaches the limit:

  1. Remove what the connector no longer uses: entities with no flow writing them, field mappings for properties nobody reads, flows for event types the source stopped sending, when/transform steps left over from a migration.
  2. Move long static values (lookup tables, large templates) out of the config into a plugin, which is stored and versioned on its own.
  3. Split the integration across two connectors, for example one per source system or one per group of entities. Each connector has its own limit.

Concurrency

The draft records the version that was active when it was started, as base_version (shown in GET /draft); you never send it. If your draft was started when version 3 was active but someone else published version 4 in the meantime, POST /publish fails with HTTP 409 and {"detail": {"current_active_version": 4, "your_base_version": 3}}, and the dashboard shows the DraftConflictModal:

  • Reset draft: drop your changes and start from version 4 (POST /api/connectors/{id}/draft/reset).
  • Keep editing: keep your draft as it is; you’ll resolve manually.

Concurrent edits to the draft itself are a separate check. Every draft write endpoint accepts an optional expected_version query parameter, compared with the draft’s draft_version (also in GET /draft, bumped by each write): on a mismatch the write is refused with HTTP 409 and {"detail": {"error": "draft_version_conflict", "current_version": N}}. Without the parameter the write applies over whatever is there.

Publish review

A publish can create custom properties and pipelines on your live HubSpot portal, and HubSpot cannot rename them or fully remove them afterwards. The publish review lists what a publish would create, so it can be checked before anything reaches the portal. The dashboard shows it in the Review changes drawer.

EndpointVerbPurpose
/api/connectors/{id}/publish-reviewGETWhat the publish would create, and whether it needs a confirmation.
/api/connectors/{id}/publish-review/confirmPOSTConfirm the review for the current draft revision.
{ "policy": "dry_run_required", "draft_version": 17, "required": true, "confirmed": false, "artifacts": [ { "kind": "property", "object_type": "contacts", "name": "loyalty_tier", "label": "Loyalty tier", "entity": "contact" }, { "kind": "pipeline", "object_type": "deals", "name": "Webshop orders", "label": "Webshop orders", "entity": null } ], "volume": [], "volume_caveat": null }
FieldMeaning
policynone or dry_run_required: whether a publish of this connector that creates something on the portal has to be confirmed first.
draft_versionThe draft revision the review describes.
requiredtrue when the policy asks for a review and artifacts is not empty: only then is the publish held until you confirm.
confirmedtrue when the current draft revision has been confirmed.
artifactsWhat the draft would create that the active version did not declare: property (with the entity that maps it) or pipeline (name is the pipeline label). Read from the config alone, without a call to the portal. Properties HubSpot or an app reserves (hs_ and app-prefixed names) are left out, because a publish never creates them; removed mappings are left out too, since removing creates nothing.
volume, volume_caveatOn GET, empty unless you ask for them with ?include_volume=true; the confirmation returns any estimate already computed, without reading the sources again. Per source, an estimate of how many records or files the first sync would read. kind says what the number is: exact, lower_bound (only part was read, for example the first page of a poller) or not_estimable (count is then null). basis says what was measured. Filling it reads your own source systems, so it is off unless asked for, and the answer is kept for a short while.

The GET always answers 200, whatever the policy, so you can show the list to everyone; only required says whether the publish is held.

The confirmation gate applies only when it is enabled for your organization; by default policy is none and no publish is held. When it is enabled and the draft would create something on the portal, POST /publish without a confirmation of the current draft revision is refused with 409:

{ "detail": { "code": "publish_review_required", "message": "Publishing would create 1 custom property, 1 pipeline on your live HubSpot portal. HubSpot cannot rename or fully remove them afterwards, so review the list and confirm it before publishing.", "policy": "dry_run_required", "draft_version": 17, "artifacts": [ ... ] } }

To go ahead, confirm the revision you reviewed and publish again:

curl -X POST https://app.plugsync.com/api/connectors/{id}/publish-review/confirm \ -H "Authorization: Bearer <organization API key>" \ -H "Content-Type: application/json" \ -d '{"draft_version": 17}'

The confirmation answers with the review, confirmed: true. It holds for that revision only: any later write to the draft raises draft_version and asks for a new confirmation. Confirming a revision that is no longer the current one is refused with 409 and detail.code stale_publish_review, with your_draft_version and current_draft_version: read the review again and confirm what it shows now. A publish that creates nothing on the portal, for example one that only changes mappings to existing properties or flows, is never held.

Both 409s carry detail.code, which is how you tell them apart from the base version conflict above. All three are in the error catalog.

Restore

The dashboard lets you restore an older version into the draft (POST /api/connectors/{id}/versions/{version}/restore). Restore does not auto-publish; it copies the old version’s config into your draft and leaves the active version untouched. You then review and publish (or further edit) explicitly.

Restoring a version published before this connector’s config carried sources leaves the draft’s current source declarations alone: that version says nothing about them, and reading its silence as “no sources” would turn a restore into a deletion. The diff against such a version compares your declaration with the connector’s actual sources instead, so the first publish after the switch shows only what really changed.

Publishing from the CLI

plugsync push writes your local connector directory to the draft and stops there: nothing is published unless you pass --publish, which runs preflight first and refuses to publish if preflight reports an error (the draft stays saved either way). That default is what keeps an automated push from publishing by accident. See the CLI reference.

Last updated on