Skip to Content

EventSources

A flow’s source field names an EventSource. EventSources are the channels events travel on: every event carries a source_name and an event_type, and plugsync runs the flows whose source and event_type match them.

Definition and state

A source is two things, and they are managed apart on purpose.

Its definition is type and config: which HubSpot property changes emit an event, a poller’s URL and schedule, a webhook’s signature algorithm, a REST destination’s base URL and credential binding. That is the half worth reviewing before it goes live, so it lives in the connector config document, under the top-level sources key, one entry per source:

{ "sources": [ { "name": "shop_webhook", "type": "webhook", "config": {"inbound": {"verify_signature": "hmac_sha256", "event_type_path": "$.event"}} }, { "name": "crm_api", "type": "rest", "config": {"outbound": {"base_url": "https://api.example-crm.com", "credential_name": "crm_api_key"}} } ] }

It is versioned exactly like entities and flows: a change is written to the draft, shows up in the diff before you publish, ships with the version you publish, and comes back with a rollback. plugsync pull brings it down as one file per source under sources/ (see Event sources in the directory).

Its state is everything a definition cannot describe: the enabled flag, the webhook secret, and what the source accumulates by running (poll cursors, last poll, last error). None of that is worth reviewing and none of it belongs in a version - rotating a secret is not a config change - so it stays on the source itself, and every write to it takes effect at once.

HalfWhatWritten toIn effect
Definitiontype, configthe draft’s sourcesat the next publish
Stateenabled, the secret, cursors, last poll, last errorthe source itselfimmediately

Two rules follow from the definition living in a versioned document.

The declaration is authoritative. Publishing reconciles the connector’s sources with what the config declares: a name it declares and the connector does not have is created (with a generated secret where the type needs one), a name whose config differs is rewritten, and a source the config no longer declares is deleted, secret included. The enabled flag, the secret and the state of a source that stays are never touched by a publish. Removing a source that has advanced past its starting point is allowed, and the preflight warns you which position it was at and what becomes of it - which is not the same for every type, see Removing a source that has been running.

type is immutable under a given name. Changing it would mean silently resetting that source’s cursor and secret inside a publish, so it is refused with 422 source_type_immutable wherever it is attempted. Dropping the source and declaring a new one with the same name is the explicit path, and the diff shows both halves of it.

Names are unique per connector and match ^[a-z0-9_]{1,64}$ (lowercase letters, digits and underscores; no hyphens). The name event_api is reserved for the source of type event_api, and that type for that name. The name hubspot is reserved too: only a source of type hubspot may take it, because hubspot is what flow.source and step.target already mean, so no flow could route to a source of another type under that name. Creating a source of any other type with either reserved name is refused with 422.

A config always declares its sources, even when it has none: sources: [] says the connector has no source at all, and publishing it removes any it still has. A write that drops the key instead of emptying it is refused with 422 sources_key_removal_not_supported, so a body that simply forgot to mention its sources is never read as “delete them all”.

The API

/api/connectors/{connector_id}/sources remains the one place a source is managed. Which half of it a call touches decides where the write lands and what comes back. Every error code of these routes is listed in the API error catalog.

CallWrites toResponse
POST /sourcesthe draft’s sources202 with the pending definition, pending_publish: true and the draft version. No secret: see Secrets. A secret in the body is 422 secret_not_declarable
PATCH /sources/{name} with config and/or typethe draft’s sources202, same body. The patch is merged into the declared config (see Updating a definition); a type other than the current one is 422 source_type_immutable
PATCH /sources/{name} with enabledthe source200, in effect immediately
PATCH /sources/{name} with name (rename)the source, and the draft’s flows and sources, in one transaction200, in effect immediately. 422 while a published flow still names the old name; 409 when the new name is already taken, by a source or by a declaration with no source yet; 404 source_not_published for a source that only exists in the draft (rename its entry there instead)
PATCH /sources/{name} mixing the two halves (e.g. enabled + config)nothing422 mixed_row_and_definition_fields: send one call per half
DELETE /sources/{name}the draft’s sources (entry removed)202 with pending.op: "delete"; 204 when the source was only declared and never published, since nothing is left pending. 409 source_referenced_by_draft_flows when a flow of the draft still reads from it or writes to it
POST /sources/{name}/rotate-secretthe source200 with the new plaintext, once. 404 source_not_published for a source that only exists in the draft
POST /sources/{name}/test-connection, .../test-fetchnothing (read-only)200, run against the pending definition when there is one, the live source otherwise. The body says which in tested: "pending" or "live"
POST /sources/{name}/test-webhook, .../reprocess-filethe source200. 404 source_not_published for a source that only exists in the draft
POST /sources/{name}/full-extractthe source202: the backfill is queued, not run in the request. 404 source_not_published for a source that only exists in the draft
GET /sources, GET /sources/{name}nothing200 with the live source plus a pending block whenever the draft says something different. See Reading a source

The same definitions can be written straight to the draft, under /api/connectors/{connector_id}/draft/sources/{name} (PUT to replace one, PATCH to merge into it, DELETE to remove it), alongside the entity and flow helpers. PUT/PATCH /draft carry sources in the config document like any other key. See the draft -> publish workflow.

A definition write answers 202, not 200

202 Accepted is the honest answer to a definition write: the change has been recorded in the draft and nothing about the running connector has changed yet. It becomes real at the next publish, and only then.

curl -X PATCH https://app.plugsync.com/api/connectors/<connector_id>/sources/crm_api \ -H "Authorization: Bearer <token>" \ -H "Content-Type: application/json" \ -d '{"config": {"outbound": {"base_url": "https://api.acme-crm.com"}}}'
{ "name": "crm_api", "pending": { "op": "update", "type": "rest", "config": {"outbound": {"base_url": "https://api.acme-crm.com", "credential_name": "crm_api_key"}} }, "pending_publish": true, "draft_version": 12, "message": "Declared in the draft; it takes effect at the next publish ..." }

A script that reconciles sources must take that into account: reading back the source right after the write returns the live definition with the change under pending, not merged into config. Publish (POST /api/connectors/{connector_id}/publish), then read back. Checking the status code alone and calling it converged is exactly the failure 202 exists to make impossible.

Reading a source (GET)

GET /sources lists every source of the connector, GET /sources/{name} fetches one, and both report the live source plus a pending block whenever the draft differs from it:

{ "id": "0f8c...", "connector_id": "3a1e...", "name": "crm_api", "type": "rest", "enabled": true, "has_secret": false, "config": {"outbound": {"base_url": "https://api.example-crm.com", "credential_name": "crm_api_key"}}, "pending": { "op": "update", "type": "rest", "config": {"outbound": {"base_url": "https://api.acme-crm.com", "credential_name": "crm_api_key"}} } }

pending.op is what the next publish would do to this source: create (declared, not created yet), update (declared with a different config) or delete (still there, no longer declared). pending.type and pending.config carry the declared definition, so both are null for a delete - there is nothing pending to show, only a removal - and the top-level fields keep describing the source as it runs today. pending itself is absent when the draft and the source agree.

A source that is only declared and has never been published is listed too, with pending.op: "create", so it is visible in the dashboard and reachable by test-connection before the publish that creates it. It has no live half yet, so the fields only a live source has are absent from its entry: id, enabled, has_secret, state, status, portal_id, created_at and updated_at. name, type and config describe what is declared.

Secrets

A webhook source’s secret is generated when the source itself is created, which is at publish time. It is returned exactly once, in that publish’s created_sources, and is never readable again:

{ "version": 4, "created_sources": [{"name": "shop_webhook", "secret": "<generated once>"}], "updated_sources": [], "deleted_sources": [] }

POST /sources therefore never returns a secret, and refuses one in its body with 422 secret_not_declarable rather than putting it in a versioned document. To set a secret of your own, publish the source and then rotate it: POST /sources/{name}/rotate-secret is a state write and answers 200 with the new plaintext immediately.

Testing a definition before you publish it

test-connection and test-fetch are read-only and run against the pending definition when there is one, so a brand-new source can be checked before the publish that creates it, and an edited one can be checked before the publish that applies it. The response says which definition it used:

{"ok": true, "tested": "pending", "status_code": 200, "tested_url": "https://api.acme-crm.com/health"}

"tested": "live" means there was no pending definition and the source as it runs today was used.

The four operations that need a source that actually exists - test-webhook, reprocess-file, full-extract and rotate-secret - answer 404 source_not_published for a source that only exists in the draft. Publish it first.

Updating a definition (PATCH)

PATCH .../sources/{name} is a partial update, not a full replace: the request body is an RFC 7396 JSON Merge Patch  applied to the source’s declared config, and the result is what the next publish applies. A key present in the patch overwrites (or, for a nested object, recursively merges into) the corresponding key already stored; a key you omit is left completely untouched; a key set explicitly to null is removed, at any nesting depth, not just the top level.

Given a source whose stored config is:

{ "outbound": { "base_url": "https://api.example-crm.com", "default_headers": {"X-TenantId": "MASTER", "X-Env": "prod"} } }

sending

curl -X PATCH https://app.plugsync.com/api/connectors/<connector_id>/sources/erp_api \ -H "Authorization: Bearer <token>" \ -H "Content-Type: application/json" \ -d '{"config": {"outbound": {"default_headers": {"X-Env": null}}}}'

removes only the nested X-Env header, leaving base_url and the sibling X-TenantId header untouched: the declared config becomes {"outbound": {"base_url": "https://api.example-crm.com", "default_headers": {"X-TenantId": "MASTER"}}} and comes back as pending.config on the 202. Sending the full default_headers object as a replacement (rather than naming only the key to remove) would work too, but is not required and is the more error-prone of the two: it silently drops any header a concurrent write may have added since you last read the source.

Whether removing a key this way is accepted, and what happens next, depends on whether that key is required for the source’s type. A rest source’s config.outbound.credential_name is optional: {"config": {"outbound": {"credential_name": null}}} succeeds and leaves the source with no bound credential (its outbound requests then have no auth header until a working name is set again). A file source’s config.inbound.credential_name is required: the same patch on config.inbound.credential_name is rejected with a 422 immediately, rather than saving a definition that could never publish.

There is no PUT on this endpoint: sending one returns 405, with a detail naming PATCH as the update verb and null as the removal semantics, and an Allow header listing the methods this path actually supports. To replace a definition wholesale rather than merge into it, PUT /api/connectors/{connector_id}/draft/sources/{name} takes the complete {type, config}.

EventSourceCreate body fields

FieldTypeRequiredNotes
namestringyesUnique per connector. Regex: ^[a-z0-9_]{1,64}$
typestringyesOne of the values listed below
configobjectnoDefault {}. Keys depend on type
secretstringnoRejected with 422 secret_not_declarable: a secret is not part of a definition. See Secrets

The name regex allows lowercase letters, digits, and underscores only. Hyphens are not allowed.

Minimal create example

curl -X POST https://app.plugsync.com/api/connectors/<connector_id>/sources \ -H "Authorization: Bearer <token>" \ -H "Content-Type: application/json" \ -d '{ "name": "shop_webhook", "type": "webhook", "config": { "inbound": { "url_template": "/api/webhooks/{connector_id}/shop_webhook", "verify_signature": "hmac_sha256", "signature_header": "x-shop-signature", "event_type_path": "$.event" } } }'

The 202 this returns carries the declaration; the source starts receiving deliveries at the next publish, which is also what hands you its signing secret.

Reconciling sources from files

Keeping the desired definition of each source in a file and converging on every run is what the config document is for: put those definitions in the config’s sources key, or in one file per source under sources/ if you use the CLI, and plugsync push sends the whole document. Convergence is then the publish’s job - a source the document does not name is removed, one whose config differs is rewritten - with no diffing on your side and no key left behind.

Doing it through PATCH .../sources/{name} instead needs care, because PATCH is a merge, not a replace: sending the desired definition’s config as-is only ever ADDS and overwrites keys, never removes one the declaration has that the file does not. A key present in the declaration but absent from the file survives untouched, and the run reports success without the source ever matching the file.

Converging that way means computing the merge patch between the two, not sending the desired definition verbatim:

  1. GET .../sources/{name} for the current definition (read pending.config when a pending block is there, config otherwise).
  2. Diff it against the desired config: any key present in it but absent (or with a different value) from the desired one goes into the patch; a key with a different nested value recurses into the same rule at the next level down; a key whose value is unchanged is left out of the patch entirely. A key present but ENTIRELY absent from the desired definition becomes null in the patch - that’s what actually deletes it, since merely omitting it (which is what sending the desired definition as-is does) is indistinguishable from “leave it alone.”
  3. PATCH .../sources/{name} with that computed patch, not with the desired definition. Then publish: until you do, the run has converged the draft and nothing else.

Worked example: the current definition is {"inbound": {"url": "https://x.example.com", "item_path": "$.items", "dedup": false}}, and the file now wants {"inbound": {"url": "https://x.example.com", "item_path": "$.data"}} (no dedup key at all - the intent is to go back to the default). The correct patch is:

{"config": {"inbound": {"item_path": "$.data", "dedup": null}}}

url is left out of the patch (unchanged), item_path is included because its value differs, and dedup is explicitly null because the file no longer wants it - sending {"inbound": {"url": "https://x.example.com", "item_path": "$.data"}} instead (the desired definition as-is) would update item_path but leave the stale dedup: false in place, exactly the convergence gap this section exists to avoid. Declaring the whole sources entry in the config has none of this arithmetic: the document is the desired state, and the publish makes it so.

Polling a source on demand

poller, scheduled and file sources run on their own cadence (see Source types). One call runs them now instead of at their next due time:

POST /api/connectors/{connector_id}/sync POST /api/connectors/{connector_id}/sync?source=<name>

Without source, every enabled poller, scheduled and file source of the connector’s published config is polled once. With source, only the source of that name is. Either way the source’s schedule is not consulted: the poll runs whether or not the cron is due. The call answers 202 as soon as the poll is queued, not when it has run:

{"status": "queued", "message": "Sync triggered for connector my-connector", "source": "erp-orders"}

source is echoed back only when the request carried it. The events the poll finds go through the matching flows like those of a scheduled poll, and each source’s last_poll_at (see Reading a source) shows that it ran; a poller source also reports the outcome in last_tick, which stays null for scheduled and file sources.

An identical request repeated within 5 minutes of the previous one (same connector, same source or none) is accepted with the same 202 but is not run again. To poll the same source twice in a row, wait 5 minutes between the two calls.

A source that the poll would have nothing to do with is refused before anything is queued, with a structured detail (error, message, and pollable_sources, the names the call would have accepted):

StatuserrorWhen
404source_not_foundThe connector has no published source of that name. A source that exists only in the draft has to be published first
400source_not_pollableThe source is of a type that is never polled (hubspot, webhook, event_api, rest): its events are delivered to plugsync, or it is only written to
400source_disabledThe source is disabled. Enable it (PATCH /sources/{name} with enabled), then retry

Without source none of these apply: sources that are disabled or of another type are skipped. A connector that is paused or stopped on an error answers 400 in both forms. A source whose every enabled flow has an open circuit breaker is not refused: the request is accepted and the source is skipped, because its events would only wait for the flow to recover. From the CLI this is plugsync connector poll.

Source types

The valid values of type:

TypeDirectionWho emits
event_apiinboundExternal system calls POST /api/v1/events with an API key
webhookinboundExternal system POSTs to /api/webhooks/{connector_id}/{name}
pollerinboundplugsync GETs a URL on each tick
scheduledinboundplugsync emits a tick on each interval
fileinboundplugsync lists an SFTP directory on a schedule and ingests the files that match
hubspotoutbound (bidirectional trigger)plugsync polls HubSpot journal v4
restoutbound targetNot polled; used as the target of a post or upsert step

The sections below cover every type but file, whose config.inbound keys (path, patterns, parser, emission) are listed in the sources section of config-surface.md.

Ordering and parallelism

Events about the same record are processed in order, one after the other; events about different records are processed in parallel, spread over up to settings.record_buckets sequences per connector (4 by default; the rows of a file source keep their own per-file rule, below). What names the record depends on the source:

Source typeRecord key
event_apirecordKey or, for the entities shape, externalObjectId (see Processing order and throughput)
webhookthe value at config.inbound.id_jsonpath
pollerthe value at config.inbound.id_jsonpath
hubspotthe HubSpot record id of the change
fileeach row’s identity, within one file
schedulednone: a tick carries no record

Rollout. Per-record ordering is being rolled out progressively. Until it is enabled for your environment, webhook, poller, hubspot and scheduled events, and event_api events sent with data, keep the per-connector order described below for keyless events: id_jsonpath and ordering are accepted and published but have no effect on the order. The event_api entities shape and the rows of a file source are already ordered per record. Setting the keys now is safe: they take effect when per-record ordering is enabled, with no change on your side.

When a webhook or poller source gives no key (no id_jsonpath, or a delivery where it resolves to nothing), the key is the record’s identity read from the payload: the value of the field that the matched flow maps, straight from $.payload.*, onto the HubSpot identity property of the entity it upserts. Only when neither exists are the source’s events ordered per connector: one single sequence with the connector’s other keyless events, one event at a time, on the order of one event per second. A webhook or poller feed that must go faster than that needs an id_jsonpath or an identity mapped from the payload. A file source is the exception: a row whose identity cannot be read is ordered against no other row and runs in parallel with the rest of the file.

Two consequences worth checking before you declare id_jsonpath:

  • The value must identify one record. A value shared by distinct records serializes them together; a value that changes for the same record (a delivery id, a timestamp) lets two updates of that record overtake each other. On a poller the same value is also the dedup identity, so the checks in The record key on sync-events rows apply as well.
  • Order between different records is not kept. A flow that needs another record processed first (an order that must find its customer already in HubSpot) either points id_jsonpath at a field the two share, or opts out.

ordering

A top-level config key, next to inbound, accepted on every source type that carries records (webhook, poller, hubspot, file):

ValueEffect
recordPer record, as above (once per-record ordering is enabled, see the rollout note).
connectorEvery event of the source joins the connector’s single sequence, key or not, at about one event per second. For flows that depend on the order between different records.
absentThe connector’s settings.ordering applies, whose own default is record (see settings).

The source’s value wins over the connector’s. A file source accepts the key but its rows are always ordered by row identity within a file. In the dashboard, the webhook and poller forms carry the choice as Event ordering and the record id as Record id (JSONPath, optional); the connector-wide choice is Event ordering in the Settings panel. Like every definition change, it takes effect with the next publish.

A webhook ordered by the id its deliveries carry:

{ "name": "shop_webhook", "type": "webhook", "config": { "inbound": { "verify_signature": "hmac_sha256", "signature_header": "x-shop-signature", "event_type_path": "$.event", "id_jsonpath": "$.data.id" } } }

The same source kept in the connector’s single sequence adds "ordering": "connector" next to inbound; id_jsonpath is then not used for ordering.

Inbound channels

webhook

Receives POST /api/webhooks/{connector_id}/{name}. The signature is verified before the body is parsed; requests with a missing or wrong signature return 401.

config.inbound keys:

KeyDescription
url_templateInformational. The actual URL is always POST /api/webhooks/{connector_id}/{source_name}
verify_signatureAlgorithm. Currently supported: hmac_sha256
signature_headerHeader the caller puts the HMAC digest in (e.g. x-shop-signature)
event_type_pathJSONPath into the request body that resolves to the event type string (e.g. $.event)
id_jsonpathJSONPath into the request body that resolves to the record’s stable id (e.g. $.data.id). Deliveries with the same id are processed in order, deliveries with different ids in parallel. Absent, the id is taken from the flow’s identity mapping when it reads the payload directly; see Ordering and parallelism

The secret is never stored in plaintext and never returned twice. It is generated by the publish that creates the source and handed over once in that publish’s created_sources; from then on POST .../rotate-secret issues a new one, immediately. See Secrets.

Example from the sources of _examples/inbound-webhook-to-hubspot.json:

[ { "name": "shop_webhook", "type": "webhook", "config": { "inbound": { "url_template": "/api/webhooks/{connector_id}/shop_webhook", "verify_signature": "hmac_sha256", "signature_header": "x-shop-signature", "event_type_path": "$.event" } } } ]

event_api

The Event API source (source_name: "event_api") is the push channel for enterprise integrations. External systems call POST /api/v1/events (authenticated with a connector API key) and Plugsync enqueues the payload directly. No config keys are required; declare it as {"name": "event_api", "type": "event_api", "config": {}}.

The event type is taken from the request body, not from a config key. Flows matching this source receive $.payload.* populated from the fields the caller sent.

The source’s name must be the literal string event_api. POST /api/v1/events always stamps the outgoing message envelope with source_name: "event_api", regardless of what name you gave the source - there is no per-connector aliasing. A type: "event_api" declaration under any other name is rejected up front, with the error: “event_api sources must be named ‘event_api’; the ingest endpoint stamps source_name=‘event_api’, so any other name never matches a flow”. The same reasoning makes event_api sources non-renameable (PATCH .../sources/event_api with a name change 422s) and reserves event_api as a rename target for any other source. A flow’s source field must therefore also read literally "event_api" to ever match traffic from this endpoint.

poller

plugsync issues a GET to config.inbound.url on every tick, extracts items, and enqueues each one as a separate inbound event. A source that sets method to POST sends a POST with a JSON body instead (see Querying a search endpoint with POST); everything below that says “GET” applies to that request in the same way.

config.inbound keys:

KeyDefaultDescription
url(required)URL to GET. May carry the {cursor} token, see The incremental cursor
methodGETHTTP method of the request: GET or POST. Any other value is rejected with 422
body(none)JSON object or array sent as the request body on every page. Only valid with method: POST. String values may carry the {cursor} and {page_cursor} tokens, see Querying a search endpoint with POST
item_path$.JSONPath applied to the response JSON to extract the list of items. Applied to every page
list_path(none)Deprecated alias of item_path, still read for older configs. When both are set, item_path wins
event_typepolledEvent type string set on every emitted message
schedule(none)Cron expression deciding when a poll fires; absent means every tick (see below)
initial_cursor1970-01-01T00:00:00ZValue the cursor starts from, before any poll has persisted one. Read only when url carries {cursor}
cursor_jsonpath(none)JSONPath to each item’s watermark (e.g. $.updated_at). The highest value seen in a poll becomes the next cursor; without it the poll’s own start timestamp is used
next_page_jsonpath(none)JSONPath to the next-page URL in the response. Set it to follow a paginated feed’s own pages within a single poll (see Following a paginated feed)
next_page_header(none)Name of the response header carrying the cursor of the next page. An alternative to next_page_jsonpath: setting both is rejected with 422 (see A page cursor in the headers)
next_page_request_header(none)Name of the request header that sends the page cursor back. Defaults to the name in next_page_header when that is set and body does not carry {page_cursor}
max_pages10Cap on how many pages a single poll follows
deduptrueSuppress items this source already enqueued unchanged (see below)
id_jsonpath(none)JSONPath to each item’s stable id, used as its dedup identity, as its ordering key (see Ordering and parallelism) and as the record key on its sync-events rows (see The record key on sync-events rows)
credential_name(none)Name of the Plugsync credential authenticating the GET. Absent means an unauthenticated request, which is the common case. Must be an api_key, bearer_token or oauth2_client_credentials credential: a GET has no body, so the creds-in-body (login) type usable on an outbound target has nothing to carry here. Validated when the definition is written and again at publish, not only at the first poll: a name that resolves to zero, or to a login credential, is rejected with 422
auth_typeby typebearer for a bearer_token credential, header for an api_key one. Only meaningful with credential_name set, and not used for an oauth2_client_credentials credential, which always sends Authorization: Bearer <token>
auth_headerX-API-KeyHeader name carrying the secret when auth_type is header
default_headers(none)Static headers (e.g. {"Accept": "application/json"}) sent on every GET of the poll, followed pages included, with or without credential_name. Stored in the source config as written: never put a secret here, use secret_headers
secret_headers(none)Static headers whose value is a secret, as a map of header name to the name of an api_key credential (e.g. {"X-Gateway-Key": "erp_gateway_key"}). Validated like credential_name. See Authenticating the GET

By default the poller runs every 30 seconds. If config.inbound.schedule (a cron expression, see config-surface.md) is set, the cron decides instead: the poller only fires once that schedule is due, checked on the same 30-second cadence. Items are emitted on the inbound channel.

Authenticating the GET

A poller’s GET takes the same auth as an outbound target, with the same header rules:

  • credential_name binds one credential. An api_key or bearer_token credential writes the header chosen by auth_type. An oauth2_client_credentials credential sends Authorization: Bearer <token>: the token is fetched from the credential’s token_url and reused across polls until it is close to expiry, rather than requested on every poll. See OAuth2 client_credentials destinations for the credential’s fields and how token endpoint errors are classified.
  • default_headers are sent as written.
  • secret_headers read each value from an api_key credential, as described in Secret static headers.

On a header name that appears in more than one place the later layer wins: default_headers, then secret_headers, then the header credential_name produces. Names are compared case-insensitively, and a header listed in both default_headers and secret_headers is rejected with 422 when the definition is written. The source definition and GET on the source only ever show credential names, and no header value is logged.

{ "name": "erp_orders", "type": "poller", "config": { "inbound": { "url": "https://erp.example.com/api/orders?since={cursor}", "item_path": "$.orders[*]", "cursor_jsonpath": "$.updated_at", "credential_name": "erp_keycloak", "default_headers": {"Accept": "application/json"}, "secret_headers": {"X-Gateway-Key": "erp_gateway_key"} } } }

Every GET of this poll, followed pages included, carries Accept: application/json, X-Gateway-Key with the value of the erp_gateway_key credential, and Authorization: Bearer <token> from the erp_keycloak credential. If a credential does not resolve when the poll runs (deleted since, or its token endpoint refused the client), the poll issues no GET at all and the error is reported on the source as the failure of its last poll (see Reading the last poll). A poller with none of these keys sends the same unauthenticated GET as before.

The dashboard’s poller form edits all five keys. The Testa connectivity check on a poller (POST /sources/{name}/test-fetch) issues an unauthenticated request regardless of these keys, so a feed that requires auth answers it with 401 or 403 even when the source is configured correctly.

Calling the HubSpot API with a private app token

A poller can read from the HubSpot API itself, authenticated with the access token of a HubSpot private app instead of the connector’s OAuth connection. Store the token as a bearer_token credential: a bearer_token credential is sent as Authorization: Bearer <token>, which is the header HubSpot expects.

POST /api/credentials { "name": "hubspot_private_app", "credential_type": "bearer_token", "data": {"value": "<private app access token>"} }
{ "name": "hubspot_companies", "type": "poller", "config": { "inbound": { "url": "https://api.hubapi.com/crm/v3/objects/companies?limit=100&properties=name,domain", "item_path": "$.results[*]", "next_page_jsonpath": "$.paging.next.link", "id_jsonpath": "$.id", "credential_name": "hubspot_private_app" } } }

HubSpot returns the next page as an absolute link under paging.next.link, so the poll walks the pages as described in Following a paginated feed. For an incremental read, the CRM search endpoint takes its filter in a JSON body: see Querying a search endpoint with POST.

What to keep in mind:

  • The token decides which portal is read and what can be read there: the private app must have the read scopes of the objects the poller calls, and the portal is the one the private app belongs to, not necessarily the one the connector is bound to.
  • The token authenticates only this poller. The connector’s writes to HubSpot, and the HubSpot journal source, keep using the connector’s OAuth connection.
  • To rotate the token, update the credential’s data with PUT /api/credentials/{id}: the poller references it by name, so no re-publish is needed.
Which kind of feed do you have?

Three shapes, three configurations. The distinction matters because getting it wrong is silent: the poll still returns 200, the source still shows as healthy, and the events simply never arrive.

  • An incremental feed, one that takes a “changed since” parameter (GET /orders?since=2026-09-01T00:00:00Z) and answers with what changed. Put the {cursor} token where that parameter goes and set cursor_jsonpath to the item field the feed filters on. Each poll then asks only for what is new.
  • A paginated feed with an opaque cursor, one that answers {"items": [...], "next": "..."} and expects its own token back to hand you the following page. Set next_page_jsonpath (here $.next) and the pages are walked inside a single poll. Add {cursor} on top only if the same endpoint also accepts a changed-since parameter; the pagination token itself is never what {cursor} carries.
  • A feed with no incremental parameter at all, one that answers “every open order” on every call. Leave the URL static and let the inbound dedup below do the work: it is on by default and is exactly what this case needs.

The failure worth naming, because it is the one that looks like a platform fault: putting {cursor} in the URL of a paginated cursor feed and expecting plugsync to send back the next token the feed just returned. It never does. What goes into the URL is a timestamp, the feed does not recognise it, it answers with its first page again, the dedup drops that page as already seen, and the source polls forever with zero events delivered and no error anywhere. Use next_page_jsonpath for that feed.

The incremental cursor

A poller source has a cursor only if config.inbound.url, or a string value of config.inbound.body, contains the literal token {cursor}. Without it every poll is the same request, and no cursor is kept or sent.

With it, the token is replaced on every poll by the current cursor value: URL-encoded in the URL (an ISO-8601 timestamp arrives as 1970-01-01T00%3A00%3A00Z), as it is in the body. The value starts at initial_cursor, or at 1970-01-01T00:00:00Z when that is not set, which means the first poll asks for everything the feed is willing to serve.

It is a watermark, not a page token. The contract is: whatever plugsync sends, the feed must interpret as “give me what changed after this”. The next link in the response body is only ever used for page following; it is never stored as the cursor and never sent back in the {cursor} position.

After a poll that emitted everything it fetched, the cursor advances to:

  1. the highest value of cursor_jsonpath across the items fetched in that poll, or
  2. the timestamp taken just before the poll’s first request, when cursor_jsonpath is not configured or no fetched item carries a value at that path.

The fallback is taken before the first request on purpose: a record modified while the poll was running falls after that mark and is picked up by the next poll instead of being skipped.

Two consequences of how the values are compared and where they come from:

  • Comparison is lexicographic, on strings. ISO-8601 timestamps sort correctly (2026-09-05T10:00:00Z > 2026-09-05T09:00:00Z). Unpadded numeric ids do not: "9" sorts above "100", so a numeric sequence stalls at the first single-digit value it meets. Point cursor_jsonpath at a timestamp, or at an id the feed pads to a fixed width.
  • The watermark is computed over the items fetched, not the ones enqueued. A poll whose items are all suppressed by the dedup still advances the cursor, which is what keeps a feed from re-reading the same window forever once it has been fully consumed.

The cursor does not move when:

  • the poll failed. Any page answering something other than 200 fails the whole poll, nothing is emitted, the reason lands on the source’s state.last_error and the next poll re-reads the same window. Re-delivery is preferred over loss throughout this path: the same item may reach a flow twice, which an upsert against a stable identity absorbs.
  • the page walk was cut short by max_pages (see below).

The cursor is kept per connector and source name. It is not part of the source’s state and is not returned by any endpoint. Renaming a source therefore restarts its cursor from initial_cursor (or from the epoch default), which on a large feed means one very wide poll before it catches up again.

Publish-time hint: GET /api/connectors/{connector_id}/preflight (warning-only draft validation, rendered in the dashboard’s Review-changes drawer) flags a poller whose URL carries {cursor} while cursor_jsonpath is unset, with the warning poller_cursor_without_watermark. It never blocks anything, and it is expected on a feed where you deliberately rely on the poll-timestamp fallback; it is there because a config that saves and publishes cleanly is no evidence that the feed understands the value being sent.

Following a paginated feed

next_page_jsonpath names where, in a response, the URL of the next page lives ($.next, $.links.next, $.paging.next.link). While that path resolves to a non-empty string, the poll keeps fetching, concatenating each page’s items, up to max_pages pages (default 10).

All of this happens inside one poll, not across ticks: a walk of five pages is five GETs in the same poll, and the events from all five are enqueued together. Between polls only the cursor survives.

The next-page value is resolved relative to the URL of the page it came from, so three shapes work:

The feed returnsResolved against https://api.example.com/v1/pending
https://api.example.com/v1/pending?cursor=abc123 (absolute)unchanged
/v1/pending?cursor=abc123 (root-relative)https://api.example.com/v1/pending?cursor=abc123
?cursor=abc123 (query only)https://api.example.com/v1/pending?cursor=abc123

A bare token does not: a feed returning "next": "abc123" yields https://api.example.com/v1/abc123, because the value is read as a path segment replacing the last one. No configuration assembles a URL around a naked token, but it can be sent back where the feed expects it: in a request header (see A page cursor in the headers) or in the body of a POST (see Querying a search endpoint with POST). In both cases the value at next_page_jsonpath stops being read as a URL: the same URL is requested again and the value travels as a cursor.

If a next-page link is still there after max_pages pages, the walk stops and the poll is marked truncated: the cursor is left exactly where it was, and state.poller_truncated_at on the source (readable with GET /api/connectors/{connector_id}/sources/{name}) records the start timestamp of that poll. The next poll re-reads the same window from the same cursor. This is deliberate: with pages still unread, advancing the cursor past a first page could skip them permanently. A poll that completes its walk clears the marker back to null. A source that never clears it is asking for a higher max_pages, a narrower window, or both.

A truncated poll is also declared in the outcome of the poll itself, last_tick.walk = truncated (or repeated_page, below), with a message saying what to look at (see Reading the last poll).

Equal pages do not stop a walk; a link that stops advancing does. Two pages with the same items are not by themselves a fault: a feed can serve the same record twice and then move on, so the walk keeps following the next-page link exactly as described above, and a walk that reaches the end of the feed is complete whatever repeated on the way. One case is cut short, because asking again cannot change the answer: the same URL was requested twice in a row, both answers carried the same non-empty items, and the response points at that same URL once more. The poll stops there instead of repeating that request up to max_pages, keeps the cursor where it was, and reports last_tick.walk = repeated_page. The other way to get repeated_page is a walk that did reach max_pages with a next-page link still pending after at least one page carried the same items as the page before it: that poll is truncated like any other capped walk, and the outcome adds that a page repeated, which is the usual trace of a next-page value read from the wrong place or sent back in a way the feed ignores. Two pages with no items are never a repeat. A walk paged by a cursor sent back to the feed has its own ending for a cursor that comes back unchanged (see A page cursor in the headers); that ending is complete and is not affected. A one-shot full-extract walk applies none of this.

state.poller_truncated_at after each kind of poll:

The polllast_tick.walkstate.poller_truncated_atIncremental cursor
Reached the end of the feedcompletecleared to nulladvances
Read max_pages pages with a next page still pendingtruncated, or repeated_page if a page repeated the one before itset to the start of that pollstays
Stopped before max_pages on a link that stopped advancingrepeated_pagecleared to null (the cap was not what stopped it)stays
Stopped on a link that stopped advancing exactly at page max_pagesrepeated_pageset to the start of that pollstays
Failed (the feed answered with an error)carried over from the earlier pollleft as it wasstays

“Incremental cursor” applies to a source that polls with a {cursor} token; a source without one has no cursor to hold, and its next poll simply starts from the first page again.

Querying a search endpoint with POST

Some APIs expose their list as a search: the filter goes in a JSON body and the method is POST. Set method to POST and declare the body in body:

{ "name": "crm_invoices", "type": "poller", "config": { "inbound": { "url": "https://api.example.com/v1/invoices/search", "method": "POST", "body": { "filter": {"updated_after": "{cursor}"}, "limit": 100, "after": "{page_cursor}" }, "item_path": "$.results[*]", "cursor_jsonpath": "$.updated_at", "next_page_jsonpath": "$.paging.next_after", "id_jsonpath": "$.id" } } }

The body is sent as JSON, with Content-Type: application/json, on every page of the poll. Two tokens are replaced inside its string values, at any depth:

TokenReplaced by
{cursor}The incremental cursor, exactly as a {cursor} in the URL but not URL-encoded. It is the same value on every page of a poll
{page_cursor}The page cursor returned by the previous page, read from next_page_jsonpath or from next_page_header. It changes on every page and is never stored between polls

A value that is exactly "{page_cursor}" takes the cursor with the JSON type the feed returned it in (a number stays a number), and on the first page, where there is no cursor yet, that member is left out of the body altogether. A token embedded in a longer string ("offset:{page_cursor}") is replaced by the cursor as text, and by an empty string on the first page; an array element that is exactly the token is likewise an empty string on the first page.

With the example above the first request of a poll carries {"filter": {"updated_after": "2026-09-01T00:00:00Z"}, "limit": 100}. If the response holds "paging": {"next_after": "200"}, the second request goes to the same URL with "after": "200" added, and so on until a response carries no value at next_page_jsonpath or max_pages is reached.

Rules checked when the definition is written, each answered with 422:

  • body must be a JSON object or array, and is only accepted together with method: POST.
  • {page_cursor} is not supported in url. A feed that takes its page token in the query string returns a next-page link, which next_page_jsonpath follows as it is.

Two limits: body cannot contain null, as an object member or as an array element, at any depth (a null in a source definition means “remove this key”, so it could not be kept as a value: the write is rejected with a 422, leave the member out instead), and credentials never travel in the body, so credential_name takes the same credential types as on a GET.

GET /api/connectors/{connector_id}/preflight flags a source that sends a page cursor back, in the body or in a request header, without declaring where to read it (next_page_header or next_page_jsonpath), with the warning poller_page_cursor_never_read: such a source only ever fetches its first page.

A page cursor in the headers

Other APIs hand the page cursor over in a response header and expect it back in a request header. next_page_header names the response header to read:

{ "name": "inventory_records", "type": "poller", "config": { "inbound": { "url": "https://api.example.com/v2/records?limit=500", "next_page_header": "X-Next-Records-Id", "item_path": "$.records[*]", "id_jsonpath": "$.id" } } }

While a response carries a non-empty X-Next-Records-Id, the poll requests the same URL again with that value in the X-Next-Records-Id request header. A response without the header, or with an empty one, ends the walk. Header names are matched case-insensitively.

When the API reads the cursor from a header with a different name, set it in next_page_request_header:

"next_page_header": "X-Next-Records-Id", "next_page_request_header": "X-Start-After"

next_page_request_header also works with next_page_jsonpath, for a feed that returns the cursor in the response body and reads it from a request header. The combinations:

Cursor read fromCursor sent back inKeys
response headerrequest header of the same namenext_page_header
response headerrequest header of another namenext_page_header + next_page_request_header
response headerPOST bodynext_page_header + {page_cursor} in body
response bodyrequest headernext_page_jsonpath + next_page_request_header
response bodyPOST bodynext_page_jsonpath + {page_cursor} in body
response body, as a link(the link is followed)next_page_jsonpath alone

The cursor header is sent in addition to default_headers, secret_headers and the credential’s header, so it needs a name of its own. A definition whose cursor header is Authorization, Host, Content-Length, Transfer-Encoding or Content-Type, a key of default_headers or secret_headers, or the header the credential is sent in (auth_header, or X-API-Key when a credential is bound with auth_type header or with no auth_type) is rejected with a 422 when it is written. The comparison ignores case. The check applies to the header the cursor is sent in: a next_page_header that is only read, because the cursor goes back in the body or under next_page_request_header, can carry any name. max_pages and the truncation rule above apply unchanged: a cursor still pending after max_pages pages marks the poll truncated and leaves the incremental cursor where it was. Two more things end a page-cursor walk, and both end it as complete rather than truncated: a page with no items, and a response that hands back the same cursor the request just sent. Some feeds keep returning a cursor past their last page, and without this rule every poll of such a feed would stop at max_pages and never advance the incremental cursor. Neither applies to a feed paged by following a link (next_page_jsonpath alone), which is walked as described in Following a paginated feed. A page cursor lives inside one poll: every poll starts again from the first page.

To check the configuration before publishing, POST /sources/{name}/test-fetch sends the first-page request with the declared method and body ({cursor} replaced by the value the cursor starts from) and reports in has_next_page whether that response pointed at a further page the poll would fetch: true, false, or null for a source that declares neither next_page_header nor next_page_jsonpath. A next-page value counts the way a poll counts it: any non-empty value when the source sends a page cursor back, only a string (a URL to follow) otherwise.

test-fetch is read-only on the plugsync side (it stores nothing and moves no cursor), but the request is real: for a POST source it sends the declared POST with the declared body to the source’s URL. Run it only against an endpoint for which that request is a query.

Inbound dedup

Not every feed is incremental. A source that answers “all open orders” rather than “orders changed since X” re-serves the same items on every single poll, and each one used to become another inbound event: transport-level deduplication only covers a 5-minute window, and a cron poller’s interval is normally wider than that. In production this has reached tens of thousands of duplicate events from one source.

The poller therefore keeps a dedup ledger and, by default, does not re-enqueue an item it has already enqueued unchanged. An item’s identity has two parts:

  • The key - what makes this “the same item” across polls: the value at id_jsonpath (e.g. $.id). Set it whenever the feed has a stable id.
  • The content discriminator - what makes it “the same version of that item”: a SHA-256 over the item’s canonical JSON. Key order in the feed is normalised away, so a source that serialises fields in a different order between polls is not mistaken for an update.

So: same key and same content is a duplicate and is dropped; same key with different content is a real update and is enqueued. With no id_jsonpath configured, the key is the content hash itself - identical items still dedup, but nothing distinguishes an update from a new item, which is why setting id_jsonpath is worth it on any feed that has an id.

Only hashes are stored; the ledger never holds the item’s values.

Retention: 7 days. The window has to be wider than the poll interval or consecutive polls would never meet the same entry, and a daily or weekly cron is the practical ceiling. It also has to stay finite, so the ledger can never mask a feed permanently: an item that a broken source re-serves forever is re-emitted once per window rather than dropped for good. The window is measured from the last time the item was actually enqueued, not from the last time it was served: a suppressed item does not refresh it.

Why an item already served may not reappear. An unchanged item that the feed keeps serving stays silent until its window lapses. Usually that is the point, but it also applies when the event that item became was never processed: a drain on the connector discards the event after the poller has recorded it, so after the drain the item stays suppressed for up to seven days from the drained enqueue. To get it back sooner, change its content at the source, or re-send the record from your side through a path that bypasses the poller (the drain section lists the options).

It never blocks ingest. If the ledger is unavailable the poller emits everything, exactly as if the feature were not there, and logs the degradation.

It never costs you an item. An item is recorded as seen only after the event has been accepted for processing, so a poll that fails partway through records nothing for the items it did not send and they come back on the next poll. The dedup keeps the poller’s at-least-once delivery intact; the residual case it accepts is the opposite one, a crash between the enqueue and the record, which re-emits a handful of items rather than losing them.

Set "dedup": false to turn it off for one source. The case for doing so is a feed where an identical re-serve genuinely is a second occurrence - an append-only event stream with no stable item id, where two byte-identical events mean two real events. On a feed of records (orders, contacts, products) leave it on.

Two boundaries worth knowing: a one-shot full-extract walk on this source is not deduped (its re-emissions are part of its own resumability contract), and neither are the webhook or file inbound paths.

Reading the last poll

A poll that emits nothing can mean two different things: the feed had nothing new, or the feed keeps serving items that were already enqueued and the dedup drops them. Both look the same from the events list, so every poller source reports the outcome of its last poll in last_tick, returned by GET /api/connectors/{connector_id}/sources/{name} and by the list, and shown on the source in the dashboard:

"last_tick": { "at": "2026-09-25T10:00:30Z", "error": null, "counts_at": "2026-09-25T10:00:30Z", "fetched": 252, "emitted": 0, "suppressed": 252, "suppressed_reasons": {"already_queued": 252, "repeated_in_response": 0}, "dedup": "on", "pages_fetched": 1, "walk": "complete", "notices": [] }
FieldMeaning
atWhen the last poll ran.
errorWhy it failed, or null when it succeeded.
fetchedItems the poll read from the feed, across all its pages.
emittedItems that became inbound events.
suppressedItems dropped by the dedup: fetched = emitted + suppressed.
suppressed_reasons.already_queuedItems identical to one an earlier poll already enqueued within the retention window.
suppressed_reasons.repeated_in_responseItems served more than once in the same poll: only the first is emitted.
dedupon, off when the source sets "dedup": false (nothing is ever suppressed), or degraded when the ledger was unavailable on that poll and everything read was emitted.
pages_fetchedPages the poll requested.
walkHow the page walk ended: complete (the feed pointed at no further page), truncated (stopped by max_pages with pages still to read) or repeated_page (the poll did not reach the end of the feed and a page repeated the one before it, see Following a paginated feed).
noticesWhat the poll asks you to look at: a list of {"code", "message"}. Empty when the walk completed, and on a failed poll whose counts are carried over from an earlier one (counts_at older than at): that walk is not this poll’s.
counts_atWhich poll the counts, pages_fetched and walk belong to.

A failed poll does not wipe the counts. It sets at and error and keeps the counts of the last poll that got as far as reading the feed, so counts_at older than at tells you the latest poll produced no counts of its own. Before any poll succeeds the counts are null. The next successful poll clears error.

Only the last poll is kept: last_tick is overwritten on every poll, it is not a history. It is null until the first poll and for every other source type. Reporting it never moves the incremental cursor. A step of a one-shot full-extract walk is not an incremental poll and leaves last_tick as it was.

A walk that did not complete carries one notice. The code is stable, the message is in English and says what to look at:

walknotices[].codeWhat happenedWhat to do
truncatedpoller_walk_truncatedThe poll read max_pages pages and the feed still pointed at another one. A source that polls with a {cursor} token keeps its cursor, so the next poll reads the same window again.Raise max_pages, or narrow what one poll has to read.
repeated_pagepoller_page_repeatedThe poll did not reach the end of the feed, and at least one page carried the same items as the page before it. A source that polls with a {cursor} token keeps its cursor until a poll reaches the end.If the feed really returns identical pages in a row, raise max_pages so one poll can reach the end. If it does not, the feed is handing the same page back: check where the next-page value is read (next_page_jsonpath or next_page_header) and how it is sent back. A {cursor} token carries a timestamp, not the feed’s page token.
"last_tick": { "at": "2026-10-07T08:15:30Z", "error": null, "counts_at": "2026-10-07T08:15:30Z", "fetched": 300, "emitted": 0, "suppressed": 300, "suppressed_reasons": {"already_queued": 200, "repeated_in_response": 100}, "dedup": "on", "pages_fetched": 3, "walk": "repeated_page", "notices": [ { "code": "poller_page_repeated", "message": "The last poll read 3 pages without reaching the end of the feed, and at least one page carried the same items as the page before it. ..." } ] }

The dashboard shows the same messages under Last poll on the source, and marks the source in the list. pages_fetched, walk and notices are null, null and empty on a record written before October 2026, until the source polls again. The stored state.last_tick holds walk and pages_fetched only: notices is part of the last_tick field of the response.

The reading that matters most: fetched equal to suppressed_reasons.already_queued on poll after poll means the feed is re-serving the same window. On a feed that is not incremental that is the dedup doing its job. On an incremental one it points at the cursor: the URL has no {cursor} token, the feed ignores the value it receives there, or cursor_jsonpath reads a value that does not move.

The record key on sync-events rows

The value at id_jsonpath is also the record’s key on every sync-events row its event produces: the row’s external_id carries it whatever the outcome (success, failed or skipped), and the same holds for the items of a full-extract walk. To see every attempt at one record, filter the list by it:

GET /api/connectors/{connector_id}/sync-events?external_id=PRA-000056

The match is exact. Inside a flow the same value is readable as $.external_id.

If the source already declared id_jsonpath and its flow derives the record’s identity from a different payload field, the rows’ external_id switches from that derived identity to the value at id_jsonpath from the first poll after this change. The HubSpot identity match is unaffected; only what the rows show, and what the filter above matches, changes.

An item whose value at id_jsonpath is missing, empty, an object or a list, or longer than 500 characters is still enqueued and processed normally; only its rows carry no key. A poller without id_jsonpath behaves the same way for every item: its rows keep external_id empty (unless the flow itself derives a key from the payload), because nothing in the feed has been named as the record’s identity and plugsync does not invent one.

Before you declare id_jsonpath on a source that is already live, know three things:

  • The first poll after the change re-enqueues the whole feed. The dedup identity of every item changes from its content to its key, so nothing the ledger holds matches any more and every item the feed serves goes out again, once. On a feed that serves thousands of records that is thousands of events in one poll: publish the change at a time when that volume is acceptable for the connector and its target, and tell whoever watches the target.
  • Suppression still depends on content. A key alone never drops an item: same key with different content is an update and is enqueued. Declaring the key does not stop state changes from propagating; it only lets the ledger recognise that two versions belong to the same record.
  • Check that the field really identifies one record. A field that looks like an id but repeats across distinct records (a case code shared by several lines of the same case, a customer code on every order of that customer) merges those records under one key: they overwrite each other’s ledger entry, and their rows become indistinguishable when filtered. Count the distinct values of the field against the number of items the feed serves before declaring it. If they differ, pick a field that is unique per item, or leave id_jsonpath unset so that content stays the identity.

scheduled

By default, emits a tick every 30 seconds. If config.inbound.schedule (a cron expression, see config-surface.md) is set, the cron decides instead: a tick is emitted only once that schedule is due, checked on the same 30-second cadence. Use this to drive periodic flows (nightly reconciliation, heartbeats, etc.).

With a schedule, a source added by a publish first fires at the first slot of its cron after that publish, not at the publish itself: a 0 1 * * * source published at 15:24 UTC first fires at 01:00 UTC the next night. A later publish that keeps the source does not move that first slot. To run it right away, poll it on demand with POST /api/connectors/{connector_id}/sync?source=<name> (see Polling a source on demand); from then on the cron counts from that run.

config.inbound keys. The inbound block must be present even when it sets nothing ("config": {"inbound": {}}): a source without it never fires.

KeyDefaultDescription
schedulenoneCron expression (UTC). Without it, a tick every 30 seconds
tick_event_typetickEvent type string on the emitted message

The scheduled source emits on the inbound channel. The flow receives $.payload as {"scheduled_for": "<ISO 8601 UTC timestamp>"}: the time of the cron slot the tick fires for, the same on a retry, a late run or a replay (without a schedule, the moment the tick fired). A scheduled export counts its day from it.

The HubSpot journal source

Type hubspot, config: {}.

This source listens to the HubSpot portal the connector itself is bound to (see Connector and HubSpot portal) — there is no separate portal selection at the source level, and several connectors can each have their own hubspot source pointed at the same portal (see the gotcha below). That binding can be changed later without rebuilding the connector, at the cost of this source’s read position and of the connector’s identity mappings: see Changing the portal.

The HubSpot journal v4 API is polled continuously and the read position is persisted, so each poll starts from where the last one left off. Where that position lives depends on which reader is in use (see the hublet paragraph below): under the global journal reader, how staging and production have run since 2026-08-31, a single app-level cursor is shared by every portal and the portal’s own source carries only the marker state.journal_global_last_seen_at; under the per-portal reader the cursor is state.portal_journal_offset on the source itself. A state.journal_offset key left over on an older source is inert: nothing writes or reads it any more, and a portal rebind clears it. Events are emitted on the outbound channel.

Portal-type availability

HubSpot does not document which portal types actually materialize the journal-local: the official docs only say the endpoint is beta, gated to allow-listed developer apps, with a 3-day retention. Nothing about which portal types the journal actually shows up on. The table below is what plugsync has observed directly, not a HubSpot guarantee, and it is updated by hand whenever a new portal type is tried, not automatically. The two availability columns are two different readers, not two answers to one question: journal-local is the per-portal endpoint the per-portal reader reads; the global journal reader walks the app-level stream, and is how staging and production run (see the hublet paragraph below). A cell says “Yes” only where plugsync has actually observed it:

Portal typeJournal-local (per-portal path, own hublet only)Global journal reader (app-level stream)EvidenceObserved
Sandbox, NA1 hubletYesExpected, not separately verifiedEvents delivered continuously on a sandbox portal while it was still on NA12026-07-29 to 2026-08-21
Production (live customer portal), NA1 hubletYesExpected, not separately verifiedEvent delivery verified end-to-end on a live portal2026-07-24
Developer test accountNo, neverYesJournal-local: a fully provisioned developer test account (subscriptions created, no error) produced zero journal entries for the whole observation window, and state.portal_journal_offset was never seeded (2026-08-27 to 2026-08-28). Global reader: a contact update on a developer test account on the EU1 hublet was delivered to the target about 12 s after the change, and state.journal_global_last_seen_at moved accordingly (2026-09-01)2026-08-27 to 2026-09-01
Any portal on a hublet other than the app’s home hublet (e.g. EU1)No, HTTP 488YesJournal-local: a portal migrated to EU1 on 2026-08-21 answers 488 (“Hub X is unknown to this Hublet, but it appears to exist in Hublet eu1”). Global reader: delivered end-to-end on an EU1 portal (row above)since 2026-08-21; 2026-09-01
Partner demo portalNo dataNo dataNot tried yet-
Enterprise trial portalNo dataNo dataNot tried yet-

Developer test accounts never materialize the journal-local - but they DO appear in the app-level (global) journal. A developer test account can be provisioned exactly like a working portal - subscriptions created, no error anywhere in the API - and still never emit a single journal-local entry; for the first three days the dashboard shows it exactly like a live portal that is simply quiet, then the journal_never_materialized warning appears. Under the per-portal reader, then, a developer test account cannot validate outbound sync. Under the global journal reader the same portal type works: its events show up in the app-level stream and route normally (see the matrix). The journal_never_materialized warning follows the active reader: under the global reader it names no portal type at all, it only reports that the portal has been silent since its cold start (it is raised for any portal still without state.journal_global_last_seen_at three days later). Developer test accounts stay named as the known cause only in the per-portal text, where they really are one. If a connector must run on a portal type not in the table above, run the verification protocol below before depending on the journal there, and update this table with what you find.

Journal-local is fenced per hublet, not just per portal type. plugsync’s public app is homed on an account whose hublet is NA1. Since HubSpot’s 21/08/26 EU migration, journal-local for any portal that lives on a different hublet than the app’s home one returns HTTP 488 (“Hub X is unknown to this Hublet, but it appears to exist in Hublet Y”) - this is not a transient error or a portal-type gap, it is a permanent property of the API: journal-local is scoped to the requesting app’s own hublet. The app stays homed on NA1 (there is no per-hublet app), and the customer base is mixed EU/US, so this mismatch is a standing condition, not an incident to wait out. The mitigation is the global journal reader: instead of journal-local, plugsync walks the APP-LEVEL journal (/webhooks-journal/journal/{version}, no installPortalId), which is always read from NA1 and contains every hublet’s events in one stream, demultiplexed by portalId. It is how staging and production have run since 2026-08-31; while it is in use, journal-local is never consulted for event consumption at all. A portal on a non-home hublet is therefore reachable outbound only through the global reader (or the poller fallback below). Which reader is active is a platform-wide setting, one value per environment: there is no per-org switch to look for.

Provisioning checks follow the same reader. A portal whose HubSpot subscriptions are missing is detected under either reader, on the same cadence: one subscription listing per (org, portal) pair per 15 minutes, plus one shared app-level listing per tick since that endpoint is app-scoped. Under the global reader, “this portal’s journal is silent” means “no event carrying its portalId in a walk that reached the end of the journal” - a walk cut short by the per-tick file cap proves nothing about a portal whose events may sit in the files not yet read, so those cycles publish freshness only and no verdict. “This portal’s journal has ever produced” is the OR of the pre-cutover per-portal cursor and state.journal_global_last_seen_at, the marker written whenever a walk sees an event carrying that portal’s portalId - seen, not necessarily matched to a flow or delivered. The consequence worth internalizing: a fresh last_poll_at with last_error null is not by itself evidence that this portal is healthy, because a successful app-level tick refreshes it for every portal, including one with no subscriptions at all. Subscription listing is app-level and answers across hublets (verified in production on 31/08: EU1 subscriptions list fine from NA1), so it works even on a portal where journal-local does not.

A cursor older than the retention window stalls silently, and is rehooked automatically. The three signals above answer “can this portal produce?”; this one answers “can we still read from where we stopped?”. If a portal stays quiet for longer than the journal’s 3-day retention - a long weekend, a seasonal customer, a consumer that was down - the persisted offset falls behind the oldest entry HubSpot still keeps. From then on GET .../offset/{offset}/next answers exactly like “no events” rather than with an error, so it reads as “nothing new”: the stale cursor is kept and retried forever, last_poll_at stays fresh, last_error stays null, and the connector is dead with no signal at all. plugsync now detects it - an empty walk plus an /earliest that is newer than the persisted offset, probed on the same 15-minute window as the provisioning check, never once per tick - and rehooks the cursor to that earliest entry, walking it inclusively in the same tick (/next is exclusive, so simply storing the new offset would skip precisely the file that unblocked it). What fell outside the retention window cannot be recovered: that loss is reported, never swallowed, as the journal_offset_expired status on the source, with the cursor’s delay expressed as a duration (journal offsets are UUIDv7, so their timestamps are readable and directly comparable to the 3-day retention). The warning stays visible for a week and then ages out on its own - it is the record of a past loss, not a live fault, so it never masks a stale source - and a portal rebind clears it along with the cursors. The same protection covers both cursors: the per-portal one and the global reader’s single app-level cursor, where one expiry stalls every portal at once.

A source that stops being read does not catch up afterwards, so the retention is not a buffer to count on. A hubspot source is read only while it is enabled, its connector is neither paused nor stopped on an error, a HubSpot connection is bound to the connector, and the organization’s syncs are not suspended. While it is read, last_poll_at moves on every pass, whether or not the portal produced anything; when it is not read, last_poll_at stops moving, and that is the signal you can check yourself, on the source in the dashboard or through GET /api/connectors/{connector_id}/sources/{name}. A HubSpot connection that is still bound but no longer works (revoked, expired) is a different fault: the source is still read and last_poll_at keeps moving, so look at the connection, not at this field. What happens to the HubSpot changes made during the stop depends on the reader in use. Under the global journal reader, how staging and production run, one read position serves every portal and moves on without the stopped source: the changes made meanwhile are passed over, and enabling the source or resuming the connector later starts from the present and does not replay them, however short the stop was. There is no deadline to meet, and the 3-day retention does not help. The dashboard says so where it applies: while the hubspot source of a connector is disabled, the connector page shows a warning that the changes made in HubSpot are neither collected nor replayed, with the time the source was last read, and turning the source off from the dashboard asks for a confirmation carrying the same explanation. The warning is about a disabled source only (a paused connector is not announced this way), and a source disabled through the API or the CLI is not asked to confirm. GET /api/connectors/{connector_id} carries the same facts in hubspot_source: name, enabled, last_read_at (the source’s last_poll_at) and stop_loses_changes, which is true under this reader and false under the per-portal one; the field is null for a connector with no hubspot source and on the connector list. Under the per-portal reader the read position stands still with the source, so the changes wait and are delivered on resume, as long as the resume comes before the oldest of them leaves the 3-day retention; if another source of the same organization on the same portal kept running, the position moved on with it and the changes are passed over as above. In both cases, after a stop, check what you expected to sync instead of assuming a replay. The plugsync platform team is alerted when a source has not been read for 48 hours, one day short of the retention, once per stop. Do not rely on an email for this: no notice to the organization owners is sent by default, and none is ever sent where the read position has already moved on, for a test connector, for a connector with no HubSpot connection, or for an organization whose syncs are suspended or that is being closed.

If the journal turns out unavailable on a given portal - hublet mismatch included - the poller source type is the fallback for outbound sync: it does not depend on the journal at all, at the cost of polling instead of near-real-time delivery.

Verifying journal delivery on a new portal (~10 min)

Run this whenever connecting a portal type not already covered by the matrix above, instead of waiting to find out from a customer that nothing is arriving:

  1. Connect the portal via OAuth, the normal way (getting started, step 3).
  2. Publish a minimal connector with one hubspot-type event source and at least one flow whose source names it. HubSpot subscriptions are auto-derived from the published flows - see Referencing a source from a flow - there is nothing to subscribe by hand.
  3. Make one foreign change to an object of a subscribed type, directly in the HubSpot UI, not through plugsync, so the change is genuinely external.
  4. Watch state.journal_global_last_seen_at on the source, read through GET /api/connectors/{connector_id}/sources/{name} (the API table at the top of this page). The dashboard panel does not show it, and last_poll_at is not a substitute: it moves on every successful tick whether or not this portal produced anything. The marker is written when an event carrying this portal’s portalId goes through the app-level walk; the per-portal cursor is never touched, so an absent state.portal_journal_offset means nothing here. Only on a deployment running the per-portal reader instead (a local dev stack, unless configured otherwise) watch state.portal_journal_offset. The marker should move within about two minutes of the change in step 3. If it does, the journal is live on this portal. If it is still missing after that window, first rule out a backlog: while the reader is catching up (walks hitting the per-tick file cap) a portal’s marker can lag well past two minutes and proves nothing either way. Otherwise treat the portal type as unsupported for the journal and fall back to a poller source instead - then add the result to the matrix above.

The order of these steps is not incidental:

  • Subscriptions are not retroactive. HubSpot only journals a change if a matching subscription already existed at the time it happened. Publishing the flow (step 2) before making the change (step 3) is required - reversing the order produces a false negative, not a slower positive.
  • Journal-local retention is 3 days. Even on a portal where the journal is live, anything older than that is gone, so this is a live probe, not something to reconstruct from history after the fact.

Event type format: {objectTypeId}.{action} lowercased, where the action is one of create, update or delete. Examples:

HubSpot eventevent_type on the event
Contact created0-1.create
Contact updated0-1.update
Company updated0-2.update
Deal updated0-3.update
Company deleted0-2.delete

A flow may declare modified as an alias of update (0-1.modified is read as 0-1.update); events themselves always carry the canonical form. Apart from the wildcard {objectTypeId}.*, any other action word, such as created or changed, matches no journal event: the flow is never subscribed and never runs.

Enriched payload shape: before enqueuing, plugsync fetches the object from the HubSpot CRM v3 API (GET /crm/v3/objects/{object_type}/{object_id}), so flows always have object.id available. It does NOT fetch every property by default. The CRM v3 GET only returns the properties named in its properties query param, and plugsync names none unless the source’s enrich_properties config key lists them (see below). With enrich_properties empty or absent, HubSpot falls back to its own default minimal set: in practice this has shown up in production as just hs_object_id, createdate, hs_lastmodifieddate, none of a portal’s custom business properties and not even built-ins like hubspot_owner_id. A flow reading $.payload.object.properties.vat_number when vat_number was never named in enrich_properties gets None silently, on every event, with no error anywhere.

The payload delivered to the flow (available as $.payload.* in step expressions) is:

{ "event": { "...raw journal event fields..." }, "object": { "id": "12345", "properties": { "hs_object_id": "12345", "createdate": "...", "hs_lastmodifieddate": "..." } } }

Steps that read HubSpot properties therefore use $.payload.object.properties.*, but only for properties actually requested, per enrich_properties below.

Reacting to which property changed (event.propertyChanges)

$.payload.event is the raw journal event HubSpot emitted, not an enrichment-time snapshot, and it carries HubSpot’s own propertyChanges: a dict {"<property_name>": "<new_value>", ...} listing exactly the properties THIS event reports as changed. On an update event that is the actual change set; on a create event it is the properties that were given a value at creation time; a delete event carries no properties at all (there is nothing left to report).

This is the field to gate a step on a specific property having changed, rather than on the object having changed at all - object.properties above is enriched independently and does not tell you which of its keys this particular event actually touched:

{"id": "sync_vat", "action": "post", "target": "erp_outbound", "entity": "company", "url": "/api/companies/vat-changed", "when": {"exists": "$.payload.event.propertyChanges.vat_number"}, "body": {"vat_number": "$.payload.object.properties.vat_number"}}

The when gate above only lets the step run for journal events whose own change set included vat_number; an unrelated update to the same company (e.g. only city changed) skips it. See when conditions for the full condition DSL, and narrow_outbound_projection for a flow-level flag built on this same field.

Routing by property: triggers

By default a hubspot source routes each journal event to the flows by its mechanical event_type, {objectTypeId}.{action}. config.triggers replaces that for the source: an event is emitted only when one of the properties it changed matches a trigger, and it is routed to that trigger’s own event_type, so several flows on the same object type and action can each own a property change. Each entry:

FieldRequiredMeaning
property_nameyesHubSpot internal name of the property whose change fires the trigger.
event_typeyesThe event_type the matching event is routed to (e.g. company.update), referenced by a flow’s event_type.
patternnoRegex that must match anywhere in the property’s new value; anchor it with ^...$ for an exact match. Absent = any new value.
object_typenoThe HubSpot object type the trigger applies to: a standard name (contacts, companies, deals, tickets, products, line_items, singular accepted) or an objectTypeId (0-2). A custom object is identified only by its objectTypeId (2-12345), never by name: p_lead or leads is rejected. Absent (or null) = every object type.

Several triggers can match the same journal event (two properties changed in one entry, or two patterns on the same property): each match emits its own event. An event no trigger matches is dropped silently, with no error and no retry.

HubSpot property names are unique per object type only: a trigger_update_matchpoint property on companies and one with the same internal name on contacts are two distinct properties. Without object_type, a trigger on that name fires for both, so a change on a company would also emit the contact trigger’s event_type. Declare object_type on each trigger to keep them apart:

{ "name": "hubspot_journal", "type": "hubspot", "config": { "triggers": [ {"property_name": "trigger_update_matchpoint", "object_type": "companies", "event_type": "company.update"}, {"property_name": "trigger_update_matchpoint", "object_type": "contacts", "event_type": "lead.update"} ] } }

A change of trigger_update_matchpoint on a company now emits only company.update. The source’s journal subscription follows the same restriction: a trigger with object_type subscribes its property on that object type only, one without it on every object type the connector’s entities map onto HubSpot.

object_type is validated when the source is saved, whether through POST/PATCH /sources or the sources key of the draft: a value that is not a HubSpot object type, or one contradicting the object type the trigger’s event_type names (for example "object_type": "companies" with "event_type": "contact.update", or with 0-1.update), is rejected with 422. A custom object’s name is not accepted: declare its objectTypeId (2-12345). An event_type whose prefix names no object type (lead.update for a custom object) accepts any object_type.

Triggers apply to property changes only. A real HubSpot deletion carries no properties, so it is always routed by the flows (see the next section).

Reacting to a deletion ({objectTypeId}.delete)

Deleting an object in HubSpot emits a journal event like any other change, so a flow reacts to it by declaring the mechanical event_type {objectTypeId}.delete (or a wildcard {objectTypeId}.* covering it):

{"name": "company_deleted_to_erp", "source": "hubspot_journal", "event_type": "0-2.delete", "steps": [{"id": "drop", "action": "plugin", "plugin": "erp_ops"}]}

A deletion is the one event whose object no longer exists, which changes what the payload can carry:

  • object is always null. Enrichment fetches the object from the CRM API, and that GET necessarily 404s for a deleted record. Any step reading $.payload.object.properties.* gets None on a delete event.
  • event.propertyChanges is empty: there is nothing left to report.
  • $.payload.external_id carries the target-side id, resolved from the connector’s identity map by the HubSpot object id. It is added only on delete events, and it is the field a plugin step should key on: it is the only identifier of the record that survives the deletion. (The declarative delete step does not read it - it resolves the id itself; see the last limit below.)
{ "event": {"objectTypeId": "0-2", "objectId": 4512, "action": "DELETE", "occurredAt": 1753372800000}, "object": null, "external_id": "ERP-1042" }

Limits worth knowing:

  • Unmapped objects are skipped, silently by design. If the connector’s identity map holds no row for the deleted HubSpot id (the object was never synced by this connector, or its mapping was already removed), no event is emitted: there is nothing to delete on the target side. plugsync still records the skip with the connector, object type and object id, so a genuinely lost mapping remains diagnosable.
  • The subscription must cover DELETE. HubSpot only journals what the portal’s subscription asks for, and the subscription is derived from the flows: without a flow declaring {objectTypeId}.delete (or the wildcard), HubSpot never records the deletion in the first place. This holds even on a source that declares explicit triggers (see hubspot-triggers-to-rest): a trigger fires on a property change and a deletion carries none, so real deletions are always routed by the flows, never by a trigger, whatever the trigger’s own event_type is named.
  • Adding that .delete flow costs no extra journal volume. A subscription’s property filter applies to UPDATE only: HubSpot delivers every CREATE and every DELETE regardless of the listed properties, and merely projects the entry’s propertyChanges onto them (measured on a staging portal for all three actions). So a DELETE joining a property-filtered UPDATE subscription on the same object type keeps that filter intact, and the per-portal journal stays as narrow as it was.
  • The fallback poller does not report deletions. The HubSpot journal source is the only path that emits them, whichever journal reader is in use. A connector using a poller source instead is polling a plain REST endpoint, which has no notion of a HubSpot deletion at all.
  • A delete step resolves its own id, and needs pointing at the event. The declarative delete action against a REST target does not read $.payload.external_id: it resolves the external id from the identity map itself, keyed by the HubSpot id it reads through the entity schema’s identity_source_path. Its default chain ($.payload.object.id, then $.payload.objectId) resolves neither field on a delete event, since object is null and the id lives under event, so the target schema must declare "identity_source_path": "$.payload.event.objectId" explicitly. Forgetting it is caught at publish: the response carries the warning delete_rest_identity_source_path_unresolvable, naming the path to declare. A plugin step has no such constraint: it receives the whole payload, external_id included.

Example from the sources of _examples/outbound-hubspot-journal-to-api.json:

[ { "name": "hubspot_journal", "type": "hubspot", "config": {} } ]

enrich_properties

config.enrich_properties is a list of HubSpot internal property names to fetch when enriching a journal event. Any custom property (or non-default built-in) a flow’s steps read via $.payload.object.properties.<name> must be named here: nothing is inferred from the flows automatically.

KeyTypeDefaultDescription
enrich_propertieslist of strings[] (empty)Extra HubSpot property names fetched on enrichment. Empty/absent means only HubSpot’s own default minimal set comes back: zero of the connector’s own custom properties.

Full example: a company sync flow that pushes vat_number, pec, and hubspot_owner_id out to an external ERP on every HubSpot company update.

[ { "name": "hubspot_journal", "type": "hubspot", "config": { "enrich_properties": ["vat_number", "pec", "hubspot_owner_id"] } } ]
{ "name": "hubspot_company_to_erp", "source": "hubspot_journal", "event_type": "0-2.update", "steps": [ { "id": "push_erp", "action": "post", "target": "erp_outbound", "entity": "company", "url": "/api/companies/upsert", "body": { "vat_number": "$.payload.object.properties.vat_number", "pec": "$.payload.object.properties.pec", "owner_id": "$.payload.object.properties.hubspot_owner_id" } } ] }

Set or change it after creation with:

curl -X PATCH https://app.plugsync.com/api/connectors/<connector_id>/sources/hubspot_journal \ -H "Authorization: Bearer <token>" \ -H "Content-Type: application/json" \ -d '{"config": {"enrich_properties": ["vat_number", "pec", "hubspot_owner_id"]}}'

It is also editable from the dashboard: open the connector’s Sources panel, select the hubspot source, and use the Enrich properties field (add/remove chips, then Save trigger routing, the same save button also persists enrich_properties).

Gotcha on a portal shared by multiple connectors: each journal event is read once, not once per connector, and enrichment requests the union of the enrich_properties of every connector bound to that event’s portal. A single misconfigured connector cannot narrow what another connector on the same portal receives, but it also means the underlying HubSpot GET always requests the union: harmless for correctness, just worth knowing when reasoning about the API call’s properties param from a HubSpot rate-limit or audit-log angle.

Publish-time hint: GET /api/connectors/{connector_id}/preflight (warning-only draft validation, rendered in the dashboard’s Review-changes drawer) flags a flow that reads $.payload.object.properties.<name> for a <name> missing from its source’s enrich_properties with a warning (enrich_properties_gap). It is informational only, it never blocks publish, but it is the fastest way to catch this before the field silently comes back empty in production.

Outbound targets

Type rest. Never polled; used as the target of a post or upsert step to send data to an external API.

config.outbound keys:

KeyDescription
base_urlBase URL of the target API (e.g. https://api.example-crm.com)
credential_nameName of the Plugsync credential to use for auth. Must be api_key, bearer_token, login, or oauth2_client_credentials type. Validated when the definition is written and again at publish, not only at the first sync attempt: a name that resolves to zero, or to a credential of an incompatible type, is rejected with 422
auth_typebearer, header, or basic. Default: bearer for bearer_token credentials, header for api_key. Not used for oauth2_client_credentials (see below)
auth_headerHeader name to carry the secret when auth_type is header. Default: X-API-Key. Ignored for bearer and basic, which always write Authorization
default_headersStatic headers (e.g. {"X-TenantId": "MASTER"}) sent on every outbound request to this destination, independent of credential_name/auth_type. Stored in the source config as written: never put a secret here, use secret_headers
secret_headersStatic headers whose value is a secret, as a map of header name to credential name (e.g. {"X-Gateway-Key": "crm_gateway_key"}). Each credential must be of type api_key: its value is sent as the header value on every outbound request. The config holds only the credential name. Validated like credential_name: a name that resolves to no credential, or to a credential of another type, is rejected with 422 when the definition is written and again at publish. See Secret static headers

At runtime the credential is resolved and its auth header injected on every outbound request, merged with default_headers and secret_headers if set. On a header name that appears in more than one place the later layer wins: default_headers, then secret_headers, then the header credential_name produces. Header names are compared case-insensitively, so x-api-key and X-API-Key are the same header and only the winning one is sent. The exact header depends on auth_type: bearer writes Authorization: Bearer <secret>, header writes <auth_header>: <secret>, and basic writes Authorization: Basic <base64> over <secret>: — the secret as username with an empty password, not a user/password pair. The credential value is never logged.

The same resolved headers are what a plugin granted the auth capability for this source receives in its payload. Such a grant counts as a reference: a source consumed only that way gets the same delete/rename protection described under Referencing a source from a flow, and a grant that names no source of this connector (or one with no outbound credential to hand over) is reported as a preflight warning.

The credential itself is created once per organization with POST /api/credentials — body {"name", "credential_type", "data"}, data stored Fernet-encrypted — and referenced from any number of sources by name. What data must contain depends on the type: api_key and bearer_token take {"value": "<secret>"}; login takes {"username": "...", "password": "..."}; oauth2_client_credentials takes token_url, client_id, client_secret and an optional scope, or a private key in place of the client secret (examples below). A credential name is unique within the org (case-sensitive, matched exactly by sources): creating one with a name already in use, or renaming one into a name already in use via PUT, fails with 409. The same five types (api_key, bearer_token, login, oauth2_client_credentials, sftp) can also be created from the dashboard, under Settings → Credentials, with a form per type instead of calling the API directly.

A source’s credential_name (and every credential named in its secret_headers) is resolved by name, not by a stable reference into the credential row, so removing the name a source still relies on — by deleting the credential, or by renaming it — would silently break that source at the next sync. Both are refused the same way: DELETE /api/credentials/{id} and a PUT /api/credentials/{id} that changes name respond 409 (credential_referenced_by_sources) with the list of referencing sources. Drop or repoint the credential_name on those sources first, then rename or delete. Publish repeats the same resolution check against the credential currently in the org, for a reference that went stale by another route.

Rotating a secret is not renaming. A PUT carrying only data (or a name equal to the credential’s current one) replaces the secret in place and is never gated: every source keeps resolving the same name, and no re-publish is needed. It is the rename that is refused, because it is the one that leaves those sources pointing at a name nothing answers to.

What the publish gate covers, and what it does not. The re-check runs over every source of the connector that carries a credential_name — a rest target’s outbound one, a file or poller source’s inbound one — and over every credential a rest target or a poller names in secret_headers, but it only rejects the publish (422) for a source that call leaves both live and in use. Precisely:

Source at publish timeUnresolvable credential_name
Enabled, and the published config triggers a flow on it, posts to it, or grants a plugin auth on it422, publish rejected
Enabled file or poller source (both are ingested on the source’s own schedule, with or without a flow)422, publish rejected
enabled: falseWarning in the publish response and in the preflight, publish proceeds
Enabled rest source that no flow of the published config namesWarning in the publish response and in the preflight, publish proceeds
Declared but not created yet (pending.op: "create")422, publish rejected: creating the source resolves the reference, so there is nothing a flow could do to make the publish survive it
No longer declared, so this publish deletes itNot reported: the source is gone when the call returns

A warning is not an all-clear: the reference is still broken, and it becomes a 422 on the first publish that re-enables the source or wires it into a flow. What the narrower gate buys is that an unrelated edit still ships, and that a source can be retired in one publish - repoint the flows and drop the source from sources in the same config, and the stale credential that made it unusable no longer blocks the publish removing it. Note that “no flow names it” only downgrades a rest target, the one kind nothing reaches unless a flow routes to it: a file or poller source is ingested on its own schedule regardless of the flows, so for those two only enabled decides. Source types that carry no credential at all are outside this check because they have nothing to check.

The full endpoint set:

EndpointResult
POST /api/credentialsCreates a credential. 201 with the credential (never data). 422 on a data shape that doesn’t match credential_type, or on a name longer than 255 characters. 409 (credential_name_taken) if the name is already used in the org
GET /api/credentialsLists every credential in the org. 200, never data
GET /api/credentials/{id}Fetches one credential. 200, or 404 if it doesn’t exist (or belongs to another org)
PUT /api/credentials/{id}Updates name and/or data in place. 200 with the updated credential. 422 on a data shape that doesn’t match the credential’s credential_type, or on a name longer than 255 characters. 409 (credential_referenced_by_sources) on a rename of a credential any source still names, with the list of those sources. 409 (credential_name_taken) on a rename into a name already used; a name equal to the credential’s own current one is a rotation, neither conflict
DELETE /api/credentials/{id}Deletes a credential. 204 on success. 409 (credential_referenced_by_sources) with the list of referencing sources if any source still names it via credential_name
PATCH /api/credentials/{id}Not supported. 405 with a detail naming PUT as the update verb, and an Allow header listing the supported methods

Rotate a secret: PUT with the new data and no name; sources keep referencing the credential by name, no re-publish needed. Rename a credential: PUT with the new name, but only once no source names the old one — otherwise the 409 above.

Example from the sources of _examples/outbound-hubspot-journal-to-api.json:

{ "name": "crm_api", "type": "rest", "config": { "outbound": { "base_url": "https://api.example-crm.com", "credential_name": "crm_api_key", "auth_type": "bearer" } } }

OAuth2 client_credentials destinations

Some target APIs sit behind an OAuth2 client_credentials token endpoint (e.g. Keycloak) instead of a static API key. Create a credential of type oauth2_client_credentials with token_url, client_id, client_secret, and an optional scope:

POST /api/credentials { "name": "crm_keycloak", "credential_type": "oauth2_client_credentials", "data": { "token_url": "https://idp.example.com/realms/x/protocol/openid-connect/token", "client_id": "svc-client", "client_secret": "svc-secret", "scope": "api.write" } }

Bind it to the source the same way as any other credential:

{ "name": "crm_api", "type": "rest", "config": { "outbound": { "base_url": "https://api.example-crm.com", "credential_name": "crm_keycloak", "default_headers": {"X-TenantId": "MASTER"} } } }

plugsync POSTs token_url form-urlencoded (grant_type=client_credentials, plus scope if configured) to obtain a Bearer access token, and caches it until it is close to expiry (from the response’s expires_in): the token endpoint is hit once per credential per TTL window, never once per outbound call. A token endpoint failure is classified transient (429/5xx, worth a retry), permanent (other 4xx), or fatal (401/403: the configured client_id/client_secret is wrong), the same taxonomy used elsewhere in the sync engine for outbound HTTP errors. client_secret is stored Fernet-encrypted like every other credential; neither the client secret nor the fetched access token is ever logged.

Private key JWT client authentication

Some authorization servers (Okta, Keycloak with signed-JWT clients, many enterprise API gateways) do not accept a client secret on the token endpoint: the client proves its identity with a JWT it signs with its own private key, the private_key_jwt method of RFC 7523. Set client_auth_method to private_key_jwt and give the private key instead of client_secret:

POST /api/credentials { "name": "crm_okta", "credential_type": "oauth2_client_credentials", "data": { "token_url": "https://example.okta.com/oauth2/default/v1/token", "client_id": "0oa1b2c3d4", "client_auth_method": "private_key_jwt", "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQ...\n-----END PRIVATE KEY-----\n", "key_id": "key-2026", "algorithm": "RS256", "scope": "api.write" } }
FieldDefaultMeaning
client_auth_methodclient_secretclient_secret or private_key_jwt. A credential created without the field keeps authenticating with client_secret
private_key(required)The client’s private key, PEM, unencrypted: a passphrase-protected key is rejected. Register the matching public key (or its JWKS) on the authorization server
algorithmRS256RS256, RS384, RS512 (RSA key, 2048 bits or more) or ES256, ES384, ES512 (EC key on P-256, P-384, P-521 respectively)
key_id(none)Sent as the kid header of the JWT, so the server can pick the matching public key. Required by servers that hold more than one key per client
audiencetoken_urlValue of the aud claim, for servers that expect something other than the token endpoint URL (an issuer URL, for instance)

token_url, client_id and scope mean the same as with a client secret. The two methods do not mix: client_secret is refused on a private_key_jwt credential, and the four fields above are refused on a client_secret one, with a 422 naming the offending field. The key is checked when the credential is saved (it must parse, and match algorithm), so a wrong key type fails at POST or PUT rather than at the first sync.

On every token request plugsync signs a new assertion and sends it as client_assertion, with client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer, next to grant_type=client_credentials, client_id and scope. Its claims are iss and sub (both the client_id), aud, iat, an exp two minutes after iat, and a jti that is unique per request. The access token that comes back is cached and reused exactly as described above: the assertion is signed once per token, not once per outbound call.

To rotate the key, register the new public key on the authorization server, then PUT /api/credentials/{id} with the full data carrying the new private_key (and key_id, if it changed). Requests made after the update sign with the new key and fetch a fresh token: a token obtained with the previous key is not reused. To move an existing credential from a client secret to a private key, PUT the full data in its new shape under the same credential: the sources bound to it by name need no change and no new publish.

The private key is write-only: no API response returns it, the dashboard never shows it again after saving, and neither the key nor the signed assertion appears in logs or in a dry-run trace, where the header is rendered as Authorization: Bearer <redacted> without signing anything. If the server rejects the assertion (401/403), the failure is fatal like a wrong client secret; check that the public key registered for the client matches, that key_id and audience are what the server expects, and that the server’s clock is within a minute or so of real time.

A post step that uses this source:

{ "id": "push_api", "action": "post", "target": "crm_api", "entity": "company", "url": "/api/companies/upsert", "body": "$.payload.object.properties" }

The step url is appended to base_url, so the actual request goes to https://api.example-crm.com/api/companies/upsert.

Secret static headers

Some target APIs sit behind a gateway that expects a static key or client id on every call (an apikey or X-Gateway-Key header), often on top of an OAuth2 bearer token. That value is a secret, so it does not belong in default_headers, which is stored in the config as written. Store it as an api_key credential and name it under secret_headers:

POST /api/credentials { "name": "crm_gateway_key", "credential_type": "api_key", "data": {"value": "gw-0123456789"} }
{ "name": "crm_api", "type": "rest", "config": { "outbound": { "base_url": "https://api.example-crm.com", "credential_name": "crm_keycloak", "default_headers": {"X-TenantId": "MASTER"}, "secret_headers": {"X-Gateway-Key": "crm_gateway_key"} } } }

Every request to crm_api then carries three headers: X-TenantId: MASTER, X-Gateway-Key: gw-0123456789 and Authorization: Bearer <token> from the oauth2_client_credentials credential. credential_name is optional: a source with only secret_headers is still an authenticated destination, and a plugin granted auth on it receives the resolved headers.

What stays true for a secret header:

  • The source definition, GET on the source, plugsync diff and the config history only ever show the credential name.
  • A flow dry-run shows the header with the value <redacted>. A credential that does not resolve shows up as an outbound_auth_error warning of the dry-run, naming the header.
  • Rotating the secret is a PUT on the credential with the new data: no re-publish needed.
  • Deleting or renaming a credential a secret header names is refused with 409, like for credential_name.
  • Removing one header on PATCH follows the merge rules above: send {"config": {"outbound": {"secret_headers": {"X-Gateway-Key": null}}}}.
  • Header names are case-insensitive. Two secret_headers keys that differ only by case, or a secret header that is also a default_headers key in any case, are rejected with 422 when the definition is written: move the header from default_headers to secret_headers in the same PATCH, with null on the default_headers key.

In the dashboard, the REST destination form has a Secret headers section: one row per header, with the header name and an api_key credential picked from the org.

Referencing a source from a flow

A flow references a source in three places:

  1. flow.source - the EventSource name whose events trigger the flow.
  2. step.target (on post and upsert steps targeting non-HubSpot endpoints) - the EventSource name of the outbound rest source to POST to.
  3. A plugin step’s auth capability grant - the EventSource name whose resolved outbound headers are handed to the plugin as payload.auth[<source>]. No target names it, but the dependency is just as real: see auth: scoped outbound credentials.

All three must name a source the same config declares. The first two are enforced at publish time, on the config being published, so a flow and the source it reads go live together in one call - there is no order to get right. The third is only reported as a preflight and publish warning, never rejected, because the grant lives on an org-scoped plugin that other connectors share.

A source is removed by dropping it from sources, and that removal is refused twice over while something still names it. DELETE /sources/{name} answers 409 source_referenced_by_draft_flows when a flow of the same draft still reads from it or writes to it: repoint those flows first, in the same draft, and the removal and the repointing publish together. The publish then re-checks the same thing against the config it is publishing, and rejects with 422 listing every referencing flow and step, grants included; for a grant it also names the extra step needed to clear it, since the grant is re-embedded from the plugin at each publish and PATCH /api/plugins/{id} alone does not change the active version.

Renaming a source (PATCH with {"name": "new_name"}) follows the same rule: it is honored only when no flow of the active published version references the source (those match events by name at runtime), and returns 422 listing the blockers otherwise. References inside the draft are cascaded to the new name automatically: flow.source, step.target, step.from_schema/step.to_schema, the auth grants of an embedded plugin block, entity schema keys, and captured payload samples. The plugin’s capability allow-list is deliberately left alone (it is org-scoped, shared with the org’s other connectors) and has to be updated by hand; preflight warns while the two disagree. event_api sources cannot be renamed, and the reserved names event_api and hubspot are refused as new names. Note for webhook sources: the delivery URL contains the source name, so the external system must switch to the new URL after the rename.

To swap a source atomically while staying inside the plan’s source limit, declare the replacement and drop the one it replaces in the same config, and publish once with the repointed flows: the deletion happens after the new version goes active, and the limit is checked on the net effect of the publish, so an org sitting exactly at its cap needs no transient headroom.

For the full flow authoring syntax see authoring-a-flow.md.

Last updated on