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
| Term | Meaning |
|---|---|
| Connector | One integration between a source system and HubSpot. Owns config, credentials and API keys. |
| Entity | A 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 source | Where events come from: the Event API, an inbound webhook, a poller, a file drop, or HubSpot itself. |
| Draft and publish | Every config edit goes to a mutable draft; nothing runs until you publish a version. See the draft -> publish workflow. |
Two credentials, two planes
| Plane | Endpoints | Auth |
|---|---|---|
| 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 JWTCreate 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 nowAdd 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”.
-
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"}] -
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
upsertpath 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", ...}statusis always one ofadopted,already_mapped,not_found,ambiguousoridentity_conflict: never a silent write.ambiguous(more than one record matchesmatch_property) andidentity_conflict(the matched record already carries a DIFFERENT identity value) both refuse to write; resolve those by hand (a more selectivematch_property, or a fix to the data directly in HubSpot) and calladoptagain. Only standard objects (companies, contacts, deals) are supported: a custom-objectentity_typeis rejected with a 422. -
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.
upsertnow 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’sscopefield says which:connectororapp. 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:
emission | Rows go through | Use when |
|---|---|---|
"flows" (recommended) | The connector’s event flow manifest, exactly like any other event: field_mappings, when filters, value transforms, associate/associations steps | Your 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 entirely | Your 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": "..."}}'emission="flows": end-to-end example (recommended)
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
configyou 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. nullremoves a key, omitting it does NOT: sending{"config": {"inbound": {"csv": {"repair": null}}}}turns a previously setcsv.repairoff. Simply leavingcsv.repairout 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.fileswith a new list replaces the old list wholesale; it does not append to it or merge entries by index. config: nullitself is a no-op, not a way to wipe the whole config. PATCH the individual nested keys tonullinstead.enabledis a separate call. It is state, not definition: a PATCH with onlyenabledanswers200and applies immediately. A PATCH that mixesenabledwithconfigis refused with422 mixed_row_and_definition_fields: send one call per half. Sendingenabled: nullis a no-op, never an “unset”. The secret is never set through PATCH: usePOST .../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.recordmirrors the raw HubSpot object (id,properties,createdAt,updatedAt,archived) plus the probedassociationsmap. Readingrecord.emailinstead ofrecord.properties.emailsilently gets nothing back, not an error. - A date written through the
to_epoch_mstransform reads back asYYYY-MM-DD, not epoch milliseconds.to_epoch_msturns an ISO or date string into UTC-midnight epoch milliseconds on the way in; HubSpot’s own API returns adate-type property as a date-only string on the way out. plugsync does not touch dates on read:recordabove is HubSpot’s response, passed through verbatim (signup_datewas written as epoch milliseconds and reads back"2026-07-15"). - A field the write side dropped reads back as
null, never"". Anupsertwrites only the mapped fields that have a value: a source field that is absent ornullin the event payload, or one a transform turned intonull(e.g.blank_to_noneon an empty or whitespace string), is never sent to HubSpot at all. A property HubSpot never received a value for readsnull, never an empty string (middle_nameabove 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 503event_ingestion_unavailable, check here first.- Every response carries an
X-Request-IDheader: 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/bulkloads 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_winsandconflicts.most_recentread 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 (
sourceahubspotsource, 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-insuppress_echoworks 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:
- Edit the record in the HubSpot UI: fastest, no setup; a human edit is never attributed to plugsync’s app.
- Or write with a HubSpot private app token (Settings -> Integrations ->
Private Apps in the portal, independent of plugsync) and
PATCHthe 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
- Getting started: the same journey from the dashboard.
- Config v3 reference: drafts, entities, flows and publish.
- Event sources: every source type and the API that manages them.
- Event API: the ingestion contract.
- CLI: the same operations from the
plugsynccommand line.