Authoring a flow
Two end-to-end walkthroughs - one inbound, one outbound - then a decision table for picking the right action.
A. Inbound walkthrough - webhook to HubSpot company
Scenario: a shop sends a POST to a webhook whenever a company record changes. Plugsync should upsert that company in HubSpot.
Full config: _examples/inbound-webhook-to-hubspot.json + _examples/inbound-webhook-to-hubspot.sources.json.
Step 1 - Create the EventSource
EventSources are not part of the config blob. Create shop_webhook first; the flow’s source field will reference it by name. See event-sources.md for the full API and field reference.
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"
}
},
"secret": "<your-hmac-secret>"
}'The secret is stored encrypted and never returned by the API. Rotate it later with POST .../sources/shop_webhook/rotate-secret.
Step 2 - Define the entity
The company entity has a single hubspot schema. Because there is exactly one schema and it is named hubspot, infer_entity_mode classifies this entity as simple mode - no canonical schema, no transform step needed. See ../config-v3/simple-vs-federated.md.
{
"name": "company",
"schemas": {
"hubspot": {
"object_type": "companies",
"identity": {"strategy": "match_or_create", "property": "hs_tax_id"},
"field_mappings": [
{"source": "$.payload.name", "target": "name"},
{"source": "$.payload.vat_number", "target": "hs_tax_id"},
{"source": "$.payload.city", "target": "city"},
{"source": "$.payload.country", "target": "country"}
]
}
}
}identity.strategy: "match_or_create" with property: "hs_tax_id" means HubSpot looks up by tax ID; if no company matches, one is created. The four field_mappings read directly from $.payload.* - the inbound webhook body.
Step 3 - Write the flow
{
"name": "company_upsert",
"source": "shop_webhook",
"event_type": "upsert_company",
"event_type_from": "$.event",
"steps": [
{
"id": "upsert_hubspot",
"action": "upsert",
"target": "hubspot",
"entity": "company",
"data": "$.payload"
}
]
}Two points to note:
event_type_from: "$.event"records that the event type string lives at$.eventin the webhook body. This is a forward-compat hint only - it is persisted on the flow definition but is not resolved by the runtime. The webhook receiver is responsible for extracting the event type and placing it on the SQS envelope asevent_typebefore the message reachesflow_worker. See ../config-v3/flows.md.data: "$.payload"passes the entire inbound payload to the upsert step. Thefield_mappingsdeclared on thehubspotschema then extract the individual properties.
If the source emits a whole family of related event types (cliente.creato, cliente.aggiornato, …), one flow can cover all of them with a suffix wildcard instead of a copy per event type: "event_type": "cliente.*". See ../config-v3/flows.md#wildcard-event_type for the supported syntax and the exact-match-wins precedence rule.
Step 4 - Deploy and publish
Save the full config (entities + flows + settings) to the draft, then publish from the dashboard. The body is sent flat - the config object directly, no {"config": ...} wrapper. PATCH /draft’s legacy wrapper key is patch, not config (see ../config-v3/draft-publish.md#legacy-wrapper-deprecated); sending {"config": {...}} here does not fall back to the deprecated wrapper - it lands as a flat merge patch whose one key happens to be config, which fails with a 422 (unknown top-level config key(s) ['config']) once the merge is validated.
curl -X PATCH https://app.plugsync.com/api/connectors/<connector_id>/draft \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"version": "3.0",
"entities": [
{
"name": "company",
"schemas": {
"hubspot": {
"object_type": "companies",
"identity": {"strategy": "match_or_create", "property": "hs_tax_id"},
"field_mappings": [
{"source": "$.payload.name", "target": "name"},
{"source": "$.payload.vat_number", "target": "hs_tax_id"},
{"source": "$.payload.city", "target": "city"},
{"source": "$.payload.country", "target": "country"}
]
}
}
}
],
"flows": [
{
"name": "company_upsert",
"source": "shop_webhook",
"event_type": "upsert_company",
"event_type_from": "$.event",
"steps": [
{"id": "upsert_hubspot", "action": "upsert", "target": "hubspot",
"entity": "company", "data": "$.payload"}
]
}
],
"settings": {}
}'Then open the dashboard, click “Review changes”, and publish as v1. Workers pick up the new config within seconds via the outbox dispatcher.
B. Outbound walkthrough - HubSpot change to external CRM
Scenario: when a company is updated in HubSpot, push the change to an external CRM via its REST API.
Full config: _examples/outbound-hubspot-journal-to-api.json + _examples/outbound-hubspot-journal-to-api.sources.json.
Step 1 - Create the EventSources
Two sources are needed: a trigger source that polls HubSpot, and a target source that describes the external API.
Trigger - the HubSpot journal source (type: "hubspot", config: {}):
curl -X POST https://app.plugsync.com/api/connectors/<connector_id>/sources \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"name": "hubspot_journal",
"type": "hubspot",
"config": {}
}'source_worker polls the HubSpot journal v4 API every 30 seconds and persists its read position so no events are missed or replayed. See event-sources.md for the enriched payload shape.
Target - the external CRM REST source (type: "rest"):
curl -X POST https://app.plugsync.com/api/connectors/<connector_id>/sources \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"name": "crm_api",
"type": "rest",
"config": {
"outbound": {
"base_url": "https://api.example-crm.com",
"credential_name": "crm_api_key",
"auth_type": "bearer"
}
}
}'credential_name must match the name of an existing credential in your org (create credentials via the dashboard under Settings -> Credentials). At runtime flow_worker resolves the credential and injects the appropriate Authorization header on every outbound request. See event-sources.md for the full config.outbound field reference.
Step 2 - Write the flow
{
"name": "hubspot_company_to_api",
"source": "hubspot_journal",
"event_type": "0-2.update",
"steps": [
{
"id": "push_api",
"action": "post",
"target": "crm_api",
"entity": "company",
"url": "/api/companies/upsert",
"body": "$.payload.object.properties"
}
]
}event_type: "0-2.update" uses the HubSpot journal format {objectTypeId}.{action} lowercased. 0-2 is the HubSpot object type ID for companies; update is the action emitted by the journal. Other examples: 0-1.created (contact created), 0-3.modified (deal modified). See event-sources.md for the full event type table.
body: "$.payload.object.properties" sends the HubSpot property bag to the CRM API. The url is appended to base_url, so the actual request goes to https://api.example-crm.com/api/companies/upsert. This is the explicit-body pattern: body always wins over any crm_api schema on the company entity, so none is declared here. Omit body instead (keep entity) to have post project a entities.<entity>.schemas.<target>.field_mappings list into the outbound body automatically - see step-actions.md#body-projection. Declaring field_mappings on a non-hubspot schema that is neither a body-less post step’s target nor a transform step’s from_schema has zero runtime consumers; see entities.md for the exhaustive list of what reads what.
Step 3 - Deploy and publish
The body is sent flat, same note as step 4 of the inbound walkthrough above - no {"config": ...} wrapper (that shape is PATCH /draft’s legacy patch wrapper only, never config; see ../config-v3/draft-publish.md#legacy-wrapper-deprecated).
curl -X PATCH https://app.plugsync.com/api/connectors/<connector_id>/draft \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"version": "3.0",
"entities": [
{
"name": "company",
"schemas": {
"hubspot": {
"object_type": "companies",
"identity": {"strategy": "match_or_create", "property": "hs_tax_id"},
"field_mappings": [
{"source": "$.payload.object.properties.name", "target": "name"}
]
}
}
}
],
"flows": [
{
"name": "hubspot_company_to_api",
"source": "hubspot_journal",
"event_type": "0-2.update",
"steps": [
{"id": "push_api", "action": "post", "target": "crm_api",
"entity": "company", "url": "/api/companies/upsert",
"body": "$.payload.object.properties"}
]
}
],
"settings": {}
}'Then publish from the dashboard: “Review changes” -> “Publish v1”.
C. Choosing an action
Pick the action by intent; follow the link for the field-level signature.
| Your intent | Action | Field reference |
|---|---|---|
| Create if missing, update if present | upsert | step-actions.md |
| Insert only | create | step-actions.md |
| Modify an existing record only | update | step-actions.md |
| Remove a record | delete | step-actions.md |
Reshape the payload into $.canonical | transform | step-actions.md |
| Look up a target record before acting | find | step-actions.md |
| Push to an external REST API | post | step-actions.md |
| Best-effort side notification (must not abort the flow) | notify | step-actions.md |
| Custom code (HTTP, branching, state) | plugin (see the Plugin runtime guide) | step-actions.md |
| Conditionally do nothing / abort a branch | skip + when | step-actions.md |
| Link two already-upserted objects one pair at a time | associate | step-actions.md |
| High-volume batch write to HubSpot (v3.1) | upsert_many / create_many / update_many / delete_many | step-actions.md |
| Bulk-create associations from pre-built pairs (v3.1) | associate_many | step-actions.md |
Typos pass silently.
StepV3allows unknown fields (extra="allow"), so a typo in a step field passes draft-save without error. Copy field names exactly from the reference above; errors only surface at runtime.