Skip to Content
Getting startedAPI-first quickstart

API-first quickstart: from zero to your first synced event

This walkthrough builds a working connector with nothing but curl: no dashboard clicks. It is the same path a script or a CI job follows. If you prefer to click through the dashboard, the Getting started guide covers the same ground from the UI.

plugsync receives events from your systems (e-commerce, ERP, custom) and syncs them to HubSpot with identity mapping, custom objects and associations. You configure a connector as a draft, publish it, then push events to it.

Every example below uses two shell variables:

export BASE=https://app.plugsync.com export CONNECTOR_ID=<your connector id, from step 2>

The same guide is also served by the API itself, as plain markdown at GET /api/v1/quickstart.md (no auth required) and as the description of the API’s interactive /docs page.

Concepts

TermMeaning
ConnectorOne integration between a source system and HubSpot. Owns config, credentials and API keys.
EntityA business object the connector syncs (e.g. contact, order): field mappings plus identity rules.
Event flow”When event X arrives from source Y, run these steps” (find, upsert, associate, …).
Event sourceWhere events come from: the Event API, an inbound webhook, a poller, a file drop, or HubSpot itself.
Draft and publishEvery config edit goes to a mutable draft; nothing runs until you publish a version. See the draft -> publish workflow.

Two credentials, two planes

PlaneEndpointsAuth
Config plane (connectors, drafts, publish)/api/...Authorization: Bearer <org API key ps_org_...> (recommended for scripts and CI), or a 15-minute JWT from /api/auth/login
Event plane (ingestion)/api/v1/events...x-api-key: <connector API key ps_live_...> header. Org keys and JWTs do NOT work here.

What each credential can and cannot do, which actions only an owner can take, and how to rotate and revoke keys: Authentication, API keys and roles.

1. Authenticate (config plane)

Scripting or CI? Use an org API key (ps_org_...), not the 15-minute JWT. It is the same Authorization: Bearer token on every config-plane endpoint, with no forced expiry, no refresh calls and no cookie jar:

export TOKEN=ps_org_... curl -s $BASE/api/connectors -H "Authorization: Bearer $TOKEN" # still works hours later -- every $TOKEN below accepts an org key or a JWT

Create one from the dashboard (Settings -> API Keys, owner-only) or via the API with an owner’s JWT (an org key cannot mint other keys):

curl -s -X POST $BASE/api/org-api-keys \ -H "Authorization: Bearer $JWT" -H 'content-type: application/json' \ -d '{"label": "ci"}' # -> {"raw_key": "ps_org_...", ...} # shown only once, store it now

Add an optional "expires_at" (ISO datetime) for a key that lapses on its own. Listing and revoking keys (GET and DELETE /api/org-api-keys) are owner-only too; the fields, what a key can and cannot do and the rotation recipe are in Organization API keys.

Interactive JWT login

The short-lived JWT is meant for interactive sessions (the dashboard refreshes it for you behind the scenes); from a script it means re-authenticating every 15 minutes. If you do want it (for example, no owner has minted an org key yet, and creating one takes exactly this login plus the POST /api/org-api-keys above):

curl -s -X POST $BASE/api/auth/login -c cookies.txt \ -H 'content-type: application/json' \ -d '{"email": "you@company.com", "password": "..."}' # -> {"access_token": "...", "expires_in": 900, ...} export TOKEN=<access_token>

access_token is valid for expires_in seconds (900, i.e. 15 minutes, by default): config-plane requests start answering 401 once it lapses. Login and signup also set an httpOnly refresh cookie, so before expires_in runs out, refresh the token instead of logging in again:

curl -s -X POST $BASE/api/auth/refresh -b cookies.txt -c cookies.txt \ -H "X-CSRF-Token: $(grep plugsync_csrf cookies.txt | cut -f7)" # -> {"access_token": "...", "expires_in": 900, "active_org_id": "..."}

The CSRF cookie is readable only client-side by design: the -c cookies.txt on the login call above captures it into the cookie jar, while a browser app reads it from document.cookie. A 401 on refresh means the refresh token itself expired or was reused: log in again.

More on sessions for CLI and automation: Scripting it: long-lived sessions.

2. Create a connector from a template

Templates are complete, publishable configs: living examples of the config shape.

curl -s $BASE/api/v1/templates -H "Authorization: Bearer $TOKEN" curl -s $BASE/api/v1/templates/ecommerce_orders -H "Authorization: Bearer $TOKEN" # full config curl -s -X POST $BASE/api/v1/connectors/from-template/ecommerce_orders \ -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \ -d '{"name": "My first connector", "create_api_key": true, "api_key_label": "ingest"}' # -> {"id": "<CONNECTOR_ID>", "api_key": {"raw_key": "ps_live_...", ...}, ...} export CONNECTOR_ID=<id> export API_KEY=<api_key.raw_key>

create_api_key (default false) mints the event-plane key in the same request, saving the separate call below. POST /api/connectors (a from-scratch connector, no template) takes the same two fields. Omit create_api_key (or set it to false) and mint the key separately whenever you prefer.

3. Create the connector API key (event plane), if you skipped it above

curl -s -X POST $BASE/api/connectors/$CONNECTOR_ID/api-keys \ -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \ -d '{"label": "ingest"}' # -> {"raw_key": "ps_live_...", ...} # shown only once, store it now export API_KEY=<raw_key>

4. Publish the draft

# optional but recommended: warning-only checks (e.g. properties that will be auto-created) curl -s $BASE/api/connectors/$CONNECTOR_ID/preflight -H "Authorization: Bearer $TOKEN" curl -s -X POST $BASE/api/connectors/$CONNECTOR_ID/publish \ -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' -d '{}'

Publishing is atomic: either the new version exists or nothing changed. The new version usually applies to incoming events within a few seconds. For the full endpoint list (preflight, diff, versions, restore) see the draft -> publish workflow.

Existing portal? preflight -> adopt -> load

A portal with real history (any CRM that already has companies, contacts or deals in it) carries a day-0 risk that the plain “publish and send events” path above does not cover. upsert (match or create) only finds an existing record through the connector’s identity map or by searching on the identity property itself. A pre-existing record that has never had that property populated can never match, so the first inbound event for it silently creates a DUPLICATE instead of updating the record you already know about.

If the source delivers over a webhook (step 6) or the HubSpot journal (step 8), enable that live delivery FIRST, then run preflight, adopt and load below, not the other way around. A webhook or journal subscription enabled AFTER the historical load has run misses every delivery that fired while it was still off, and plugsync has no replay that detects or backfills that gap. Turn delivery on, confirm that at least one live event lands, THEN load the backlog: whatever changes during the load itself is already covered by the live channel, and reconciliation is just “the load ran once, delivery was never off”.

  1. Preflight (the same call as step 4 above) tells you how many records are at risk, per entity, on a best-effort basis through the HubSpot search API:

    curl -s $BASE/api/connectors/$CONNECTOR_ID/preflight -H "Authorization: Bearer $TOKEN" # -> "warnings": [{"code": "existing_records_missing_identity_property", # "message": "37 record(s) of type 'companies' in the connected # portal do not have the identity property 'external_id': ...", # "entity": "company"}]
  2. Adopt the ones you can resolve by a DIFFERENT, already-unique property (email, a VAT or tax id, an internal SKU, …) before sending real events for them. This writes the identity property onto the matched HubSpot record and saves the mapping, so the normal upsert path finds it from then on, with no duplicate on the first inbound event:

    curl -s -X POST $BASE/api/connectors/$CONNECTOR_ID/identity/adopt \ -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \ -d '{"entity_type": "company", "match_property": "domain", "match_value": "acme.com", "external_id": "ACME-042"}' # -> {"status": "adopted", "hubspot_id": "9001", ...}

    status is always one of adopted, already_mapped, not_found, ambiguous or identity_conflict: never a silent write. ambiguous (more than one record matches match_property) and identity_conflict (the matched record already carries a DIFFERENT identity value) both refuse to write; resolve those by hand (a more selective match_property, or a fix to the data directly in HubSpot) and call adopt again. Only standard objects (companies, contacts, deals) are supported: a custom-object entity_type is rejected with a 422.

  3. Load: once every record you care about is adopted (or you have accepted the duplication risk for the rest), send real events as in step 5, or use the bulk import for a large backlog. upsert now updates the adopted records instead of duplicating them.

plugsync does not detect a duplicate that has already slipped through: adopt BEFORE the first real event for each record, not after. Merging duplicates that were already created is a manual HubSpot operation.

5. Send your first event

The business payload goes under data (NOT payload). The event value must match a flow’s event type; the flows are in the template config from step 2 (the ecommerce_orders template’s contact flow listens for contact_synced):

curl -s -X POST $BASE/api/v1/events \ -H "x-api-key: $API_KEY" -H 'content-type: application/json' \ -d '{"event": "contact_synced", "data": {"email": "jane@example.com", "firstname": "Jane", "lastname": "Doe"}}' # -> 202 {"event_id": "...", "status": "processing", ...} curl -s $BASE/api/v1/events/<event_id>/status -H "x-api-key: $API_KEY"

Naming note: the HTTP field is data; inside event flow manifests the same object is addressed as $.payload.*.

The full request and response contract, the processing statuses and the error codes are in the Event API reference; see also Check processing status.

To send several records in one request, use entities instead of data: event stays required, each entry carries its own type, externalObjectId and properties, and the flow that runs is the one declared on {type}_synced. The record id then arrives as $.external_id, not inside $.payload, so the entity has to read it from there: map it onto the identity property for a HubSpot target, and set identity_source_path: "$.external_id" on a schema for any other target that declares identity. A complete request and the mapping are in The entities shape.

A flow’s event_type can also be a suffix wildcard instead of an exact string: event_type: "customer.*" matches every concrete event starting with customer. (customer.created, customer.updated, …), so one flow covers a whole event family instead of one copy-pasted flow per event. When several flows match the same concrete event, only the most specific one runs: an exact flow beats every wildcard, and between wildcards the longest prefix wins (customer.person.* beats customer.* on customer.person.updated, while customer.* keeps handling the rest of the family). $.event_type inside the flow context is always the real event, never the pattern. See Wildcard event_type.

Multi-entity envelopes

Some source systems send SEVERAL business objects in one event, each under its own key:

{"event": "customer.created", "data": {"customer": {"code": "C1", "name": "Acme"}, "order": {"number": "O1", "amount": 250}}}

A customer entity’s field_mappings still read the plain $.payload.code form, NOT $.payload.customer.code: declare "payload_root": "customer" on that entity (and "payload_root": "order" on the order entity) so $.payload.* resolves against the right sub-object for each. Writing "data": "$.payload.customer" on the upsert step does not do this (the field is not read there). See Multi-entity envelopes for the full reference.

6. Receive webhooks (optional)

Doing a day-0 load against an existing portal? Enable this webhook BEFORE running preflight, adopt and load: see Existing portal? preflight -> adopt -> load for why.

An event source of type webhook is served at POST /api/webhooks/{connector_id}/{source_name}. There is no auth header: the signature IS the auth. Declare the source (a definition write: it answers 202 and lands in the draft), then publish:

curl -s -X POST $BASE/api/connectors/$CONNECTOR_ID/sources \ -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \ -d '{"name": "my_webhook", "type": "webhook", "config": {"inbound": { "verify_signature": "hmac_sha256", "signature_header": "x-signature-sha256", "encoding": "base64", "event_type_path": "$.event_type", "id_jsonpath": "$.id" }}}' # -> 202 {"name": "my_webhook", "pending": {"op": "create", ...}, "pending_publish": true, ...} curl -s -X POST $BASE/api/connectors/$CONNECTOR_ID/publish \ -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' -d '{}' # -> {"version": ..., "created_sources": [{"name": "my_webhook", "secret": "<shown once>"}], ...} export SECRET=<created_sources[0].secret>

The signing secret is generated when the publish creates the source and is returned exactly once, in that publish’s created_sources. To get a new one later, call POST /api/connectors/$CONNECTOR_ID/sources/my_webhook/rotate-secret (200 with the new plaintext, once). A secret in the create body is refused with 422 secret_not_declarable. See Secrets.

verify_signature is the algorithm NAME (also supported: stripe_signing_v1, jwt_hs256), not a boolean. signature_header, encoding (base64 by default, hex opt-in, hmac_sha256 only) and event_type_path are optional, with the defaults shown above. id_jsonpath is optional too: it extracts the record’s stable id from the delivery, so deliveries of the same record are processed in sequence and different records in parallel. The full list of config.inbound keys is in the webhook reference.

Sign the raw request body with HMAC-SHA256:

BODY='{"event_type": "orders/create", "id": "O-1001", "total": 250}' SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -binary | base64) curl -s -X POST $BASE/api/webhooks/$CONNECTOR_ID/my_webhook \ -H 'content-type: application/json' \ -H "x-signature-sha256: $SIG" \ -d "$BODY"
import base64, hashlib, hmac signature = base64.b64encode( hmac.new(secret.encode(), body_bytes, hashlib.sha256).digest() ).decode()

With verify_signature: "jwt_hs256" the delivery instead carries a JWT signed HS256 with the webhook secret, in the authorization header by default (optional Bearer prefix; override the header with signature_header). exp and nbf claims are enforced when present, with tolerance_seconds of leeway (default 300):

import time from jose import jwt # pip install python-jose now = int(time.time()) token = jwt.encode({"iat": now, "exp": now + 300}, secret, algorithm="HS256") # POST with header: Authorization: Bearer <token>

A misconfigured source answers 400 with the error code webhook_source_misconfigured; a wrong signature answers 401. A valid delivery answers 202 with an event_id:

{"accepted": true, "event_type": "orders/create", "event_id": "<uuid>"}

That event_id is polled exactly like an Event API event_id (step 5):

curl -s $BASE/api/v1/events/<event_id>/status -H "x-api-key: $API_KEY"

7. Dry-run a flow before publishing

curl -s -X POST $BASE/api/connectors/$CONNECTOR_ID/draft/flows/<flow_name>/dry-run \ -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \ -d '{"input_event": {"email": "jane@example.com", "firstname": "Jane", "lastname": "Doe"}}'

input_event is the same business payload the Event API receives under data. No external system is touched; you get a per-step trace of what the flow would do.

If a step’s error says an identity property is “missing from projected properties” and your event is a multi-entity envelope (see Multi-entity envelopes), the trace appends a payload_root suggestion naming the payload key your event’s fields actually sit under: the wrong-root mistake surfaces here, before publish, not after.

8. HubSpot outbound: journal subscriptions

A flow whose source is a hubspot event source (e.g. event_type: "0-2.update" for a company update, following the {objectTypeId}.{action} convention) only fires if HubSpot’s journal actually delivers that object type and action into your portal. A HubSpot journal subscription is what makes it deliver.

Publishing takes care of the common case. At every publish plugsync derives the subscriptions the published flows need and creates the missing ones on the connector’s portal, in the background, shortly after the publish returns. Subscriptions that no flow on the portal uses any more are removed the same way. This creation is best-effort, so verify it rather than assume it: GET /api/connectors/$CONNECTOR_ID/webhook-subscriptions/portal reads straight from HubSpot what the portal already delivers, whoever created it:

curl -s $BASE/api/connectors/$CONNECTOR_ID/webhook-subscriptions/portal \ -H "Authorization: Bearer $TOKEN" # -> [{"object_type": "company", "actions": ["UPDATE"], "event_types": ["0-2.update"], ...}]

If something the published flows need is missing, POST /api/connectors/$CONNECTOR_ID/webhook-subscriptions/resync re-runs the same derivation (202, safe to repeat). Subscriptions are not retroactive: HubSpot only journals a change if a matching subscription already existed when it happened. See Verifying journal delivery on a new portal.

Manual subscriptions remain available for the deliberate cases: subscribing ahead of a publish, or narrowing a propertyChange subscription to one property. A manual subscription is never removed automatically; delete it yourself when it is no longer needed.

curl -s -X POST $BASE/api/connectors/$CONNECTOR_ID/webhook-subscriptions \ -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \ -d '{"subscription_type": "company.propertyChange", "event_type": "0-2.update", "enrich": true}' # -> 201 {"id": "...", "hubspot_subscription_id": ..., "scope": "connector", ...}

subscription_type is one of contact.creation, contact.deletion, contact.propertyChange, company.creation, company.deletion, company.propertyChange, deal.creation, deal.deletion or deal.propertyChange (contact, company and deal only). Add property_name to narrow a propertyChange subscription to one property; omit it to receive every property change on that object type. event_type is a label for the flow it feeds, shown in the dashboard: routing always matches the flow’s source and event_type against the real journal action. The same panel is available in the dashboard on the connector’s HubSpot source (“Webhook subscriptions”).

Creating a manual subscription is idempotent and additive:

  • Re-posting the same (subscription_type, property_name) returns the existing subscription with HTTP 200 instead of a duplicate, whether it was already active for this connector or the portal’s HubSpot app already delivered the exact same object type and actions (created by another connector, another environment, or outside plugsync). The response’s scope field says which: connector or app. A genuinely new subscription answers 201.
  • A subscription that overlaps an existing one for the same object type without matching it exactly is adopted when the existing one covers the requested action (answering 201), and refused with a 409 describing the conflict otherwise.
  • DELETE /api/connectors/$CONNECTOR_ID/webhook-subscriptions/{id} stops delivery for that subscription only.

GET /api/connectors/$CONNECTOR_ID/webhook-subscriptions lists only the manual subscriptions; auto_derived_count on the same response tells you how many the publish created on the portal, so an empty list next to a nonzero count is a connector served entirely by the automatic ones.

Enriched events (enrich: true, the default) carry the full HubSpot object under $.payload.object.properties.*, not a flat $.payload.properties.*. Custom (non-default) properties only appear there when listed in the HubSpot event source’s config.enrich_properties (see enrich_properties).

9. Batch-load files via SFTP (source file)

A file event source lists a remote SFTP directory on a cron schedule and matches an ordered list of filename patterns. What happens to each matched file’s CSV rows depends on config.inbound.emission:

emissionRows go throughUse when
"flows" (recommended)The connector’s event flow manifest, exactly like any other event: field_mappings, when filters, value transforms, associate/associations stepsYour file is a legacy, data-warehouse or ERP export whose column names, business rules or associations need mapping: the common case
"raw" (default)The same bulk import behind POST /api/v1/events/bulk, in chunks. It bypasses the event flow manifest entirelyYour export already uses HubSpot’s internal property names verbatim: no mapping, filters or associations needed

First, an org credential of type sftp (host, port, username plus password or private_key; host_key_fingerprint is optional, see below), needed by either mode:

curl -s -X POST $BASE/api/credentials \ -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \ -d '{"name": "erp-sftp", "credential_type": "sftp", "data": {"host": "sftp.customer.example", "port": 22, "username": "plugsync", "password": "..."}}'

Each matched CSV row is emitted through the manifest exactly like a single Event API call: it runs the entity’s field_mappings, any when filter on the flow’s steps, value transforms, and associate/associations, the same mapping, filtering and association machinery a webhook or the Event API would trigger.

Declare the source in the draft’s sources key, next to the entities and flows that process its rows, and publish once. The source and the flows that read it go live together. At publish, every pattern’s event_type must match, exactly or through a supported wildcard, the event_type of a flow whose source is this file source in the version being published. Otherwise the publish is refused with a 422 and nothing changes: no new version, no source created or updated. The 422 carries no error code, only a detail message naming the source, the files[] entry and the unmatched event_type.

1. Save the draft with a contact entity, an order entity, a when filter that skips inactive customers, an association between the two, and the erp_import source they read from:

curl -s -X PUT $BASE/api/connectors/$CONNECTOR_ID/draft \ -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \ -d '{"version": "3.0", "entities": [ {"name": "contact", "schemas": {"hubspot": { "object_type": "contacts", "identity": {"strategy": "external_id", "property": "erp_customer_id"}, "field_mappings": [ {"source": "$.payload.customer_id", "target": "erp_customer_id"}, {"source": "$.payload.email", "target": "email"}, {"source": "$.payload.active_flag", "target": "erp_active", "transform": "to_bool"} ] }}}, {"name": "order", "schemas": {"hubspot": { "object_type": "deals", "identity": {"strategy": "external_id", "property": "erp_order_id"}, "field_mappings": [ {"source": "$.payload.order_id", "target": "erp_order_id"}, {"source": "$.payload.amount", "target": "amount", "transform": "to_float"} ] }}} ], "flows": [ {"name": "erp_contact_upsert", "source": "erp_import", "event_type": "contact_row", "steps": [ {"id": "upsert_contact", "action": "upsert", "target": "hubspot", "entity": "contact", "when": {"equals": {"$.payload.active_flag": "1"}}} ]}, {"name": "erp_order_upsert", "source": "erp_import", "event_type": "order_row", "steps": [ {"id": "upsert_order", "action": "upsert", "target": "hubspot", "entity": "order", "associations": [{"to_entity": "contact", "to_external_id": "$.payload.customer_id"}]} ]} ], "sources": [ {"name": "erp_import", "type": "file", "config": {"inbound": { "credential_name": "erp-sftp", "path": "/export/incoming", "emission": "flows", "files": [ {"pattern": "*_contacts.csv", "event_type": "contact_row"}, {"pattern": "*_orders.csv", "event_type": "order_row"} ] }}} ]}'

PUT /draft replaces the whole config, so it always carries the sources key: a body without it is refused with 422 sources_key_removal_not_supported (send "sources": [] for a connector with no sources). Under emission="flows", the patterns carry no entity_type or external_id_property: identity and target live in the manifest.

2. Publish:

curl -s -X POST $BASE/api/connectors/$CONNECTOR_ID/publish \ -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' -d '{}' # -> {"version": ..., "created_sources": [{"name": "erp_import", ...}], ...}

From this point every matched CSV row is dispatched into the manifest exactly as configured above: the mappings, filters and associations you would use for a webhook or the Event API apply identically here.

POST /publish also accepts a new_sources list, an older shortcut for declaring sources at publish time. It still works and is folded into the draft’s sources, but it is deprecated: declare the source in the config instead. See new_sources and delete_sources.

Updating a source: PATCH merges, it does not replace

PATCH /api/connectors/$CONNECTOR_ID/sources/{name} with config is a definition write, like the create: it answers 202 with the declared config under pending, and takes effect at the next publish. The body is a JSON Merge Patch (RFC 7396), applied the same way to every source type, never a full replace.

  • Merge, not replace: the config you send is deep-merged into the source’s EXISTING declared config. A key you omit from the patch is left completely untouched and keeps whatever value it already had; objects merge recursively at every nesting level.
  • null removes a key, omitting it does NOT: sending {"config": {"inbound": {"csv": {"repair": null}}}} turns a previously set csv.repair off. Simply leaving csv.repair out of a later PATCH body does not disable it: it stays exactly as previously configured.
  • Scalars and arrays replace, they don’t merge: patching inbound.files with a new list replaces the old list wholesale; it does not append to it or merge entries by index.
  • config: null itself is a no-op, not a way to wipe the whole config. PATCH the individual nested keys to null instead.
  • enabled is a separate call. It is state, not definition: a PATCH with only enabled answers 200 and applies immediately. A PATCH that mixes enabled with config is refused with 422 mixed_row_and_definition_fields: send one call per half. Sending enabled: null is a no-op, never an “unset”. The secret is never set through PATCH: use POST .../rotate-secret.

The same JSON Merge Patch semantics apply to PATCH /draft (the whole connector config) and PATCH /draft/settings. Full reference: Updating a definition (PATCH).

emission="raw": HubSpot-ready payloads (bypasses the manifest)

Use raw only when the file already carries HubSpot’s own internal property names verbatim as column headers and needs no mapping, filtering or associations: typically a day-0 historical backfill from a file-based data-warehouse or ERP export. Rows go straight to the same bulk import behind POST /api/v1/events/bulk, bypassing the event flow manifest entirely: no field_mappings, no filters, no associations, no value transforms (to_bool, concat, …).

config.inbound.files entries additionally require entity_type and external_id_property (refused under emission="flows", above): the HubSpot object type and the property used as the upsert identity key. pattern matches remote filenames (no path separators, no traversal); the list order sets the processing order within a batch (e.g. contacts before the deals that reference them):

curl -s -X POST $BASE/api/connectors/$CONNECTOR_ID/sources \ -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \ -d '{"name": "erp_import_raw", "type": "file", "config": {"inbound": { "credential_name": "erp-sftp", "path": "/export/incoming", "emission": "raw", "files": [ {"pattern": "*_contacts.csv", "event_type": "contact_row", "entity_type": "contacts", "external_id_property": "erp_customer_id"}, {"pattern": "*_orders.csv", "event_type": "order_row", "entity_type": "deals", "external_id_property": "erp_order_id"} ], "csv": {"delimiter": ";", "quoting": "none", "has_header": true}, "max_file_size_mb": 100 }}}' # -> 202 {"name": "erp_import_raw", "pending": {"op": "create", ...}, "pending_publish": true, ...}

Like every definition write, it takes effect at the next publish (POST /publish, as in step 2 above).

CSV parsing, discards, and file lifecycle (both modes)

config.inbound.csv (all optional; defaults shown): delimiter (,), quoting ("standard" RFC 4180, or "none" for legacy delimited files that never escape or quote), encoding (utf-8), has_header (true; false requires an explicit columns list). A dirty row with MORE fields than the header is discarded and reported by default. Opt in to a positional repair for the one real-world case it covers (an unescaped delimiter inside a single descriptive column) with:

"csv": {"delimiter": ";", "quoting": "none", "repair": {"strategy": "merge_extra_into", "column": "description"}}

To turn repair back off later, PATCH it explicitly as null ({"config": {"inbound": {"csv": {"repair": null}}}}): omitting it from a later PATCH body leaves it active (see Updating a source).

Per-pattern override. inbound.csv applies to every pattern in inbound.files by default. When only ONE pattern’s file actually needs repair (the common case: one file layout has the dirty column, the others don’t), declare it on that pattern’s own files[i].csv instead of the source-wide default. It is merged field by field on top of inbound.csv for that pattern only, so unrelated options (delimiter, encoding, …) stay inherited from the shared default:

"files": [ {"pattern": "*_customers.csv", "event_type": "customer_row"}, {"pattern": "*_batches.csv", "event_type": "batch_row", "csv": {"repair": {"strategy": "merge_extra_into", "column": "description"}}} ], "csv": {"delimiter": ";", "quoting": "none"}

A repair configured only at the source level (inbound.csv.repair, no per-pattern override) is still checked against EVERY file it applies to: if the named column is missing from a given file’s header, that one file is parsed without repair (with a warning) instead of stopping the other patterns too.

A row with FEWER fields than the header is always discarded (a missing column is ambiguous, never repaired). Discarded and repaired counts show up in GET /api/connectors/$CONNECTOR_ID/sources/erp_import under state.files["<filename>"] (rows_total, rows_valid, rows_discarded, rows_repaired), alongside the raw lines and reasons of the first 20 discards (state.discard_samples).

Test the connection before the first real run (the “Testa” button on the source in the dashboard; read-only, it never downloads a file). It lists config.inbound.path and reports, per configured pattern, how many remote entries matched, plus the server’s host key fingerprint, which you can then pin into the sftp credential as host_key_fingerprint for the next connections. On a source that is still only declared in the draft, it tests the pending definition ("tested": "pending" in the response):

curl -s -X POST $BASE/api/connectors/$CONNECTOR_ID/sources/erp_import/test-connection \ -H "Authorization: Bearer $TOKEN" # -> {"ok": true, "tested": "live", "files_listed": 12, # "matches": [{"pattern": "*_contacts.csv", ...}], "host_key_fingerprint": "SHA256:..."}

A file already marked processed that reappears with IDENTICAL content (same sha256) is skipped silently, so redepositing a file is safe. One that reappears with DIFFERENT content is never reprocessed automatically: it is marked changed_blocked with a warning, visible in the dashboard and in state.files. Unblock it explicitly once you have confirmed the new content is correct:

curl -s -X POST $BASE/api/connectors/$CONNECTOR_ID/sources/erp_import/reprocess-file \ -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \ -d '{"filename": "20260720_contacts.csv"}'

This clears the file’s tracked state, so the next run (or “Sync now”) lists and processes it again as brand new. It is a no-op (200) if the filename is not currently tracked, so retrying after an earlier reprocess never hits a 404. On a source that exists only in the draft it answers 404 source_not_published: publish it first.

Optional config.inbound.post_action ({"type": "move", "dest": "Processed/"} or {"type": "rename", "suffix": ".done"}) marks a processed file on the remote after a successful run. It is cosmetic bookkeeping: the source’s own state.files, not the remote layout, is what decides whether a file was processed. Omit it entirely for a read-only SFTP user; nothing breaks, and state.files still deduplicates correctly.

The complete list of file source keys is in the sources section of GET /api/config-surface.

10. AI agent chat: SSE by default, or a single JSON reply

POST /api/agent/chat talks to the AI agent that edits connector drafts on your behalf (it never publishes). By default it answers as Server-Sent Events (Content-Type: text/event-stream): a data: {...} line per chunk, each with a type (text_delta, tool_use, tool_result, error, or the terminal done). A curl call without an Accept header gets this shape, the same one the dashboard uses.

Send Accept: application/json to get one JSON object when the turn ends instead, with no stream to parse:

curl -s -X POST $BASE/api/agent/chat \ -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \ -H 'Accept: application/json' \ -d '{"message": "list the entities on this connector", "connector_id": "'$CONNECTOR_ID'"}' # -> 200 {"session_id": "...", "status": "done", "content": "<full reply text>", # "tool_calls": [{"tool_name": "list_entities", "tool_input": {...}}, ...]}

tool_calls is present only when the turn actually used a tool; error is present only if the turn hit a handled failure, alongside whatever text was produced before it. Add ?quiet=true to drop tool_calls from the JSON reply (or the tool_use and tool_result events from the SSE stream) and get just the reply text. Any other Accept value, including a bare */* (the default of curl and fetch when the header is omitted) or one that already lists text/event-stream, keeps the SSE default. Either way the stored session transcript (GET /api/agent/sessions/{id}) accumulates the same full assistant text, however you asked for it.

Credentials never go in the chat: a message that carries a recognizable credential (a HubSpot token, an API key, a password, an Authorization value, a password inside a URL) is refused with 422 before anything is saved or shared with the AI model. detail.code is credential_in_message, detail.message explains what to do and detail.detected lists the kinds recognized, never the value. Recognition is by shape and deliberately lenient with ordinary configuration talk, so it is a safety net and not a guarantee: store secrets with POST /api/credentials or in the dashboard and refer to them by name. A message is limited to 32,000 characters; a longer one is refused with the standard 422 validation error.

What the agent sees of your records: when the in-app agent reads record data to diagnose a sync, part of it is masked before it reaches the AI model. Masked: the external id of a record (CU***23), the old and new values of a changed field, email addresses anywhere in an error message (j***@example.com) and phone numbers written in international form (+39 ...). Not masked: HubSpot record ids, sync event ids, error codes, timestamps, and the wording of error messages. That wording can still quote personal data the masking does not recognize, such as a person’s name, a national tax code, a phone number without the international prefix, or a value returned under a field the platform does not know. Quote the masked id from the reply or the sync event id when you follow up. This covers the in-app agent only: the MCP tools return record data to your own AI client as it is.

Long-running turns: SSE sends a periodic : ping comment so a silent round never looks like a dead connection to a proxy with an idle timeout; the JSON reply has nothing to send until the turn is complete. A round still running after about 45 seconds answers 202 {"session_id": "...", "status": "processing"} with a Retry-After header instead of holding the connection open indefinitely. The turn itself keeps running and is saved as usual.

Recover it with one call pattern: long-poll GET /api/agent/sessions/{session_id} with ?wait_seconds=<n> (capped at 45). It returns as soon as the reply lands, or 202 again if it doesn’t within n seconds. Repeat the same call until turn_status becomes "done":

curl -s "$BASE/api/agent/sessions/$SESSION_ID?wait_seconds=30" \ -H "Authorization: Bearer $TOKEN" # still running -> 202 {"id": "...", "turn_status": "pending", "messages": [...], ...} # (with a Retry-After header: call again the same way) # done -> 200 {"id": "...", "turn_status": "done", "messages": [...], ...}

This is the same GET /api/agent/sessions/{id} call every client already uses to load a session’s history, with one extra query parameter. Without wait_seconds (or with it at 0, the default) the call returns immediately, with turn_status included.

Send only one message per session while a turn is in flight: polling for the reply never involves another POST /chat, only repeated GETs. turn_status answers “does this session have an unanswered message”, not “is my specific turn done”, so a second message sent to the same session before the first reply lands blurs the two. Bound your own retry loop, too: a turn that fails with an error still ends with turn_status "done", but a turn cut off by a server restart can leave the session "pending" indefinitely. Never poll without a limit on your side.

11. Verify a synced record (read-back semantics)

GET /api/connectors/$CONNECTOR_ID/identity-mappings/record reads the live HubSpot object behind an identity mapping, using the connector’s own HubSpot connection: the fastest way to confirm from the API that a record actually landed, and with which values, without a second HubSpot token:

curl -s "$BASE/api/connectors/$CONNECTOR_ID/identity-mappings/record?entity_type=contact&external_id=CUST-042" \ -H "Authorization: Bearer $TOKEN"
{ "connector_id": "8f14e453-4b5b-4a1a-9a1b-2c6f7a9d0e11", "entity_type": "contact", "store_id": null, "external_id": "CUST-042", "hubspot_id": "151201", "hubspot_object_type": "contacts", "record": { "id": "151201", "properties": { "email": "jane@example.com", "firstname": "Jane", "lastname": "Doe", "signup_date": "2026-07-15", "middle_name": null }, "createdAt": "2026-07-15T09:32:11.000Z", "updatedAt": "2026-07-15T09:32:11.000Z", "archived": false, "associations": { "companies": [], "deals": [], "tickets": [] } } }

Three read semantics are easy to trip on here:

  • Field values live under record.properties, not at the top level. record mirrors the raw HubSpot object (id, properties, createdAt, updatedAt, archived) plus the probed associations map. Reading record.email instead of record.properties.email silently gets nothing back, not an error.
  • A date written through the to_epoch_ms transform reads back as YYYY-MM-DD, not epoch milliseconds. to_epoch_ms turns an ISO or date string into UTC-midnight epoch milliseconds on the way in; HubSpot’s own API returns a date-type property as a date-only string on the way out. plugsync does not touch dates on read: record above is HubSpot’s response, passed through verbatim (signup_date was written as epoch milliseconds and reads back "2026-07-15").
  • A field the write side dropped reads back as null, never "". An upsert writes only the mapped fields that have a value: a source field that is absent or null in the event payload, or one a transform turned into null (e.g. blank_to_none on an empty or whitespace string), is never sent to HubSpot at all. A property HubSpot never received a value for reads null, never an empty string (middle_name above is such a field; a raw "" with no transform is sent as is instead of being dropped).

For event-level checks (which record an event wrote, and with what outcome), see Verify a processed event.

Health and errors

  • GET /api/health: liveness of the API.
  • GET /api/health/data-plane: readiness of the ingest path. It answers {"status": "ok", ...} with a status per component, and 503 with "status": "degraded" and the failing components when part of the path is down. Results are cached for a few seconds. If ingestion answers 503 event_ingestion_unavailable, check here first.
  • Every response carries an X-Request-ID header: include it when you contact support.
  • Validation errors are 422 with field-level details. Plan limits answer 402 or 429 with a structured detail (error, tier, upgrade_url); see Plan limits for each plan and code, and Errors for the Event API codes.
  • POST /api/v1/events/bulk loads up to 5000 entities per request as a chunked job with its own status: see Bulk import.

Retries, failed events and replay are covered in Reliability: retry, failures, replay.

HubSpot outbound: testing without a false negative

Two runtime behaviours depend on who made a HubSpot change, and a write made through your own connector’s credentials never counts as “someone else”. Testing an outbound flow or a conflict scenario from the connector’s own API access therefore does not work reliably:

  • Conflict detection never treats your own connector’s writes as foreign, by design. conflicts.target_wins and conflicts.most_recent read HubSpot’s per-property edit history (propertiesWithHistory, which you can request yourself from the HubSpot CRM API to see who last wrote a value) and only let the CRM value win when the property’s last writer is a DIFFERENT HubSpot app than plugsync’s own. Every plugsync connector, on every org and portal, uses the SAME HubSpot app, so a write from ANY plugsync connector, including a second one, is never foreign. A second connector does not give you a second actor for this test. See Conflicts.
  • Whether an outbound flow (source a hubspot source, e.g. event_type: "0-2.update") fires for a write your own connector just made depends on the path, and can go either way. The journal carries no reliable “who wrote this” field: opt-in suppress_echo works by fingerprinting the properties your connector wrote, not by reading a change source on the event (see Echo suppression). Do not design a test that assumes your own writes are always filtered out, or that they always come back.

The recipe: make the test change through an actor that genuinely is not a plugsync connector:

  1. Edit the record in the HubSpot UI: fastest, no setup; a human edit is never attributed to plugsync’s app.
  2. Or write with a HubSpot private app token (Settings -> Integrations -> Private Apps in the portal, independent of plugsync) and PATCH the object directly through the CRM API: scriptable, for repeatable tests.

Either way, set the property to a genuinely NEW value: rewriting a value HubSpot already has may not produce a change at all, whoever writes it. Neither option needs a second connector or a paid tier: one manual UI edit is enough even within the free plan’s single-connector limit.

Where to go next

Last updated on